diff --git a/www/conservancy/settings.py b/www/conservancy/settings.py index 2f26462e..3fd906f8 100644 --- a/www/conservancy/settings.py +++ b/www/conservancy/settings.py @@ -100,7 +100,7 @@ INSTALLED_APPS = [ 'conservancy.apps.assignment', 'conservancy.apps.fossy', 'podjango', - 'usethesource', + 'usethesource.apps.UseTheSourceConfig', ] DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff --git a/www/usethesource/admin.py b/www/usethesource/admin.py new file mode 100644 index 00000000..ae5df7f3 --- /dev/null +++ b/www/usethesource/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin + +from .models import Candidate, Comment + + +class CommentInline(admin.TabularInline): + model = Comment + fields = ['user', 'message'] + extra = 0 + + +@admin.register(Candidate) +class CandidateAdmin(admin.ModelAdmin): + list_display = ['name', 'vendor', 'device', 'release_date'] + fields = [ + 'name', + 'slug', + 'vendor', + 'device', + 'release_date', + 'source_url', + 'binary_url', + 'description', + ] + inlines = [CommentInline] + prepopulated_fields = {'slug': ['name']} diff --git a/www/usethesource/apps.py b/www/usethesource/apps.py new file mode 100644 index 00000000..e7322c9a --- /dev/null +++ b/www/usethesource/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UseTheSourceConfig(AppConfig): + default_auto_field = 'django.db.models.AutoField' + name = 'usethesource' diff --git a/www/usethesource/migrations/0001_initial.py b/www/usethesource/migrations/0001_initial.py new file mode 100644 index 00000000..81a4958e --- /dev/null +++ b/www/usethesource/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.19 on 2023-10-24 08:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Candidate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='Candidate name')), + ('slug', models.SlugField(unique=True)), + ('vendor', models.CharField(max_length=50, verbose_name='Vendor name')), + ('device', models.CharField(max_length=50, verbose_name='Device name')), + ('release_date', models.DateField()), + ('description', models.TextField()), + ('source_url', models.URLField()), + ('binary_url', models.URLField(blank=True)), + ], + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True)), + ('message', models.TextField()), + ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='usethesource.candidate')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['id'], + }, + ), + ] diff --git a/www/usethesource/migrations/__init__.py b/www/usethesource/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/www/usethesource/models.py b/www/usethesource/models.py new file mode 100644 index 00000000..743efd6a --- /dev/null +++ b/www/usethesource/models.py @@ -0,0 +1,29 @@ +from django.contrib.auth.models import User +from django.db import models + + +class Candidate(models.Model): + name = models.CharField('Candidate name', max_length=50) + slug = models.SlugField(max_length=50, unique=True) + vendor = models.CharField('Vendor name', max_length=50) + device = models.CharField('Device name', max_length=50) + release_date = models.DateField() + description = models.TextField() + source_url = models.URLField() + binary_url = models.URLField(blank=True) + + def __str__(self): + return self.name + + +class Comment(models.Model): + candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.PROTECT) + time = models.DateTimeField(auto_now_add=True) + message = models.TextField() + + def __str__(self): + return f'{self.candidate.name}, {self.user}, {self.time}' + + class Meta: + ordering = ['id'] diff --git a/www/usethesource/templates/usethesource/add_comment_button_partial.html b/www/usethesource/templates/usethesource/add_comment_button_partial.html new file mode 100644 index 00000000..1a370e98 --- /dev/null +++ b/www/usethesource/templates/usethesource/add_comment_button_partial.html @@ -0,0 +1,3 @@ +<div class="mt2" hx-target="this" hx-swap="outerHTML"> + <button type="submit" class="f3 b white bg-green ph2" style="border: none" hx-get="{% url 'usethesource:add_comment' slug=candidate.slug %}">+</button> +</div> diff --git a/www/usethesource/templates/usethesource/cancel_button_partial.html b/www/usethesource/templates/usethesource/cancel_button_partial.html new file mode 100644 index 00000000..a8dd444b --- /dev/null +++ b/www/usethesource/templates/usethesource/cancel_button_partial.html @@ -0,0 +1 @@ +<button type="submit" class="b white bg-light-silver pv2 ph3" style="border: none">Cancel</button> diff --git a/www/usethesource/templates/usethesource/candidate.html b/www/usethesource/templates/usethesource/candidate.html index 1dca667b..8d396d22 100644 --- a/www/usethesource/templates/usethesource/candidate.html +++ b/www/usethesource/templates/usethesource/candidate.html @@ -1,35 +1,36 @@ {% extends "usethesource/base.html" %} +{% block head %} + {{ block.super }} + <script src="https://unpkg.com/htmx.org@1.9.6"></script> +{% endblock %} + {% block content %} {{ block.super }} <section class="pa2 mt4 mb3"> <div style="display: flex; justify-content: space-between"> <div> - <h2 class="f2 lh-title ttu mt0">Linksys WRG987-v2</h2> - <p><strong>Vendor</strong>: Linksys</em></p> - <p><strong>Device</strong>: WRG987</em></p> - <p><strong>Released</strong>: Oct 20, 2023</em></p> + <h2 class="f2 lh-title ttu mt0">{{ candidate.name }}</h2> + <p><strong>Vendor</strong>: {{ candidate.vendor }}</em></p> + <p><strong>Device</strong>: {{ candidate.device }}</em></p> + <p><strong>Released</strong>: {{ candidate.release_date }}</em></p> </div> <div class="mt2"> - <div><a href="{% url 'usethesource:download' %}" class="white bg-green db pv2 ph3 mb2">Download source</a></div> - <div><a href="{% url 'usethesource:download' %}" class="white bg-green db pv2 ph3">Download image</a></div> + <div><a href="{% url 'usethesource:download' slug=candidate.slug download_type='source' %}" class="white bg-green db pv2 ph3 mb2">Download source</a></div> + <div><a href="{% url 'usethesource:download' slug=candidate.slug download_type='binary' %}" class="white bg-green db pv2 ph3">Download image</a></div> </div> </div> - <p>The WRG1830 Wireless Router Gateway (WRG) is a component of the ZFR183x Pro Series Wireless Field Bus System. The WRG1830 provides BACnet IP connectivity to compatible Johnson Controls Field Controllers, VAV Controllers, Thermostats, and Sensors over a wireless mesh network.</p> + <p>{{ candidate.description }}</p> <h3 class="f3 lh-title mt4">Comments</h3> - <p><strong>Denver — Oct 20, 2023</strong>:<br> - I was able to run the autogen and configure scripts, but the build failed with a missing dependency XYZ.</p> + {% for comment in candidate.comment_set.all %} + {% include "usethesource/comment_partial.html" %} + {% endfor %} - <form method="post"> - <div class="mt4"> - <textarea placeholder="Add a comment..." style="width: 50em; height: 15em;"></textarea> - </div> - <div class="mt2"> - <button type="submit" class="b white bg-green pv2 ph3" style="border: none">Save</button> - </div> - </form> + {% if user.is_staff %} + {% include "usethesource/add_comment_button_partial.html" %} + {% endif %} </section> {% endblock content %} diff --git a/www/usethesource/templates/usethesource/comment_deleted.html b/www/usethesource/templates/usethesource/comment_deleted.html new file mode 100644 index 00000000..f6ed46bc --- /dev/null +++ b/www/usethesource/templates/usethesource/comment_deleted.html @@ -0,0 +1,3 @@ +{% if add and user.is_staff %} + {% include 'usethesource/add_comment_button_partial.html' %} +{% endif %} diff --git a/www/usethesource/templates/usethesource/comment_form.html b/www/usethesource/templates/usethesource/comment_form.html new file mode 100644 index 00000000..cfda8b4d --- /dev/null +++ b/www/usethesource/templates/usethesource/comment_form.html @@ -0,0 +1,8 @@ +<form hx-target="this" hx-swap="outerHTML" hx-post="{% url 'usethesource:add_comment' slug=candidate.slug %}" method="post"> + {% csrf_token %} + {{ form.message }} + <div class="mt2"> + <button type="submit" hx-get="{% url 'usethesource:add_button' slug=candidate.slug %}" class="b pointer white bg-light-silver pv2 ph3" style="border: none">Cancel</button> + {% include 'usethesource/save_button_partial.html' %} + </div> +</form> diff --git a/www/usethesource/templates/usethesource/comment_partial.html b/www/usethesource/templates/usethesource/comment_partial.html new file mode 100644 index 00000000..ccbfc0c6 --- /dev/null +++ b/www/usethesource/templates/usethesource/comment_partial.html @@ -0,0 +1,7 @@ +<div hx-target="this" hx-swap="outerHTML"><strong>{{ comment.user }} — {{ comment.time }}</strong> + {% if user.is_staff %} + <a href="#" class="f7 white bg-light-silver ph2" hx-get="{% url 'usethesource:edit_comment' comment_id=comment.id %}">edit</a> + <a href="#" class="f7 white bg-light-red ph2" hx-delete="{% url 'usethesource:delete_comment' comment_id=comment.id show_add='false' %}" hx-confirm="Are you sure you want to delete this comment?">delete</a> + {% endif %} + <br>{{ comment.message|linebreaks }} +</div> diff --git a/www/usethesource/templates/usethesource/download.html b/www/usethesource/templates/usethesource/download.html index 791e5421..792441c5 100644 --- a/www/usethesource/templates/usethesource/download.html +++ b/www/usethesource/templates/usethesource/download.html @@ -5,13 +5,13 @@ <section class="pa2 mt4 mb5"> <h2 class="f2 lh-title ttu mt0">Download</h2> - <p><strong>File</strong>: xyz-image.tar.gz</p> + <p><strong>File</strong>: {{ download_url }}</p> <p>By downloading this, you agree to use it only for the purposes of GPL compliance checking, unless otherwise permitted by the license.</p> <div style="display: flex"> - <a href="{% url 'usethesource:candidate' %}" class="white bg-silver dib pv2 ph3 mb2 mr2">Back</a> - <a href="{% url 'usethesource:download' %}" class="white bg-green dib pv2 ph3 mb2">Download</a> + <a href="{% url 'usethesource:candidate' slug=candidate.slug %}" class="white bg-silver dib pv2 ph3 mb2 mr2">Back</a> + <a href="{{ download_url }}" class="white bg-green dib pv2 ph3 mb2">Download</a> </div> </section> {% endblock content %} diff --git a/www/usethesource/templates/usethesource/edit_comment_form.html b/www/usethesource/templates/usethesource/edit_comment_form.html new file mode 100644 index 00000000..e03998bc --- /dev/null +++ b/www/usethesource/templates/usethesource/edit_comment_form.html @@ -0,0 +1,8 @@ +<form class="mb3" hx-target="this" hx-swap="outerHTML" hx-post="{% url 'usethesource:edit_comment' comment_id=comment.id %}" method="post"> + {% csrf_token %} + {{ form.message }} + <div class="mt2"> + <button type="submit" hx-get="{% url 'usethesource:view_comment' comment_id=comment.id show_add='false' %}" class="b pointer white bg-light-silver pv2 ph3" style="border: none">Cancel</button> + {% include 'usethesource/save_button_partial.html' %} + </div> +</form> diff --git a/www/usethesource/templates/usethesource/landing_page.html b/www/usethesource/templates/usethesource/landing_page.html index 5dcc0c2e..2432e6ff 100644 --- a/www/usethesource/templates/usethesource/landing_page.html +++ b/www/usethesource/templates/usethesource/landing_page.html @@ -9,16 +9,12 @@ <section class="mb5"> <h2 class="f2 lh-title ttu mt0">Candidates</h2> - <div class="mb4"> - <h3 class="f4 lh-title mt0"><a href="{% url 'usethesource:candidate' %}">Linksys WRG987-v2</a></h3> - <p class=""><em>Released Oct 20, 2023</em></p> - <p class="">The WRG1830 Wireless Router Gateway (WRG) is a component of the ZFR183x Pro Series Wireless Field Bus System. The WRG1830 provides BACnet IP connectivity to compatible Johnson Controls Field Controllers, VAV Controllers, Thermostats, and Sensors over a wireless mesh network.</p> - </div> - - <div> - <h2 class="lh-title mt0"><a href="{% url 'usethesource:candidate' %}">Linksys WRG987-v2</a></h2> - <p><em>Released Oct 20, 2023</em></p> - <p>The WRG1830 Wireless Router Gateway (WRG) is a component of the ZFR183x Pro Series Wireless Field Bus System. The WRG1830 provides BACnet IP connectivity to compatible Johnson Controls Field Controllers, VAV Controllers, Thermostats, and Sensors over a wireless mesh network.</p> - </div> + {% for candidate in candidates %} + <div class="mb4"> + <h3 class="f4 lh-title mt0"><a href="{% url 'usethesource:candidate' slug=candidate.slug %}">{{ candidate.name }}</a></h3> + <p><em>Released {{ candidate.release_date }}</em></p> + <p>{{ candidate.description }}</p> + </div> + {% endfor %} </section> {% endblock content %} diff --git a/www/usethesource/templates/usethesource/returned_comment.html b/www/usethesource/templates/usethesource/returned_comment.html new file mode 100644 index 00000000..62e91ab4 --- /dev/null +++ b/www/usethesource/templates/usethesource/returned_comment.html @@ -0,0 +1,5 @@ +{% include 'usethesource/comment_partial.html' %} + +{% if add and user.is_staff %} + {% include 'usethesource/add_comment_button_partial.html' %} +{% endif %} diff --git a/www/usethesource/templates/usethesource/save_button_partial.html b/www/usethesource/templates/usethesource/save_button_partial.html new file mode 100644 index 00000000..8ae5cdff --- /dev/null +++ b/www/usethesource/templates/usethesource/save_button_partial.html @@ -0,0 +1 @@ +<button type="submit" class="b white bg-green pv2 ph3" style="border: none">Save</button> diff --git a/www/usethesource/urls.py b/www/usethesource/urls.py index e3f03bfa..0eb550c3 100644 --- a/www/usethesource/urls.py +++ b/www/usethesource/urls.py @@ -5,6 +5,11 @@ from . import views app_name = 'usethesource' urlpatterns = [ path('', views.landing_page, name='landing'), - path('candidate/', views.candidate_page, name='candidate'), - path('download/', views.download_page, name='download'), + path('candidate/<slug:slug>/', views.candidate_page, name='candidate'), + path('download/<slug:slug>/<download_type>/', views.download_page, name='download'), + path('add-comment/<slug:slug>/', views.create_comment, name='add_comment'), + path('edit-comment/<int:comment_id>/', views.edit_comment, name='edit_comment'), + path('comment/<int:comment_id>/<show_add>/', views.view_comment, name='view_comment'), + path('delete-comment/<int:comment_id>/<show_add>/', views.delete_comment, name='delete_comment'), + path('add-button/<slug:slug>/', views.add_button, name='add_button'), ] diff --git a/www/usethesource/views.py b/www/usethesource/views.py index 64a0e1c6..e9740178 100644 --- a/www/usethesource/views.py +++ b/www/usethesource/views.py @@ -1,13 +1,81 @@ -from django.shortcuts import render +from django import forms +from django.contrib.admin.views.decorators import staff_member_required +from django.shortcuts import get_object_or_404, redirect, render + +from .models import Candidate, Comment def landing_page(request): - return render(request, 'usethesource/landing_page.html', {}) + candidates = Candidate.objects.all() + return render(request, 'usethesource/landing_page.html', {'candidates': candidates}) -def candidate_page(request): - return render(request, 'usethesource/candidate.html', {}) +def candidate_page(request, slug): + candidate = get_object_or_404(Candidate, slug=slug) + return render(request, 'usethesource/candidate.html', {'candidate': candidate, 'add': True}) -def download_page(request): - return render(request, 'usethesource/download.html', {}) +def download_page(request, slug, download_type): + candidate = get_object_or_404(Candidate, slug=slug) + url = candidate.source_url if download_type == 'source' else candidate.binary_url + return render( + request, + 'usethesource/download.html', + {'candidate': candidate, 'download_url': url}, + ) + + +class CommentForm(forms.ModelForm): + class Meta: + model = Comment + fields = ['message'] + + +@staff_member_required +def create_comment(request, slug): + candidate = get_object_or_404(Candidate, slug=slug) + if request.method == 'GET': + form = CommentForm() + else: + form = CommentForm(request.POST) + if form.is_valid(): + instance = form.save(commit=False) + instance.candidate = candidate + instance.user = request.user + instance.save() + return redirect('usethesource:view_comment', comment_id=instance.id, show_add='true') + return render(request, 'usethesource/comment_form.html', {'form': form, 'candidate': candidate}) + + +@staff_member_required +def edit_comment(request, comment_id): + comment = get_object_or_404(Comment, id=comment_id) + if request.method == 'GET': + form = CommentForm(instance=comment) + else: + form = CommentForm(request.POST, instance=comment) + if form.is_valid(): + instance = form.save(commit=False) + instance.save() + return redirect('usethesource:view_comment', comment_id=instance.id, show_add='false') + return render(request, 'usethesource/edit_comment_form.html', {'form': form, 'comment': comment}) + + +@staff_member_required +def view_comment(request, comment_id, show_add): + show_add = show_add == 'true' + comment = get_object_or_404(Comment, id=comment_id) + return render(request, 'usethesource/returned_comment.html', {'comment': comment, 'candidate': comment.candidate, 'add': show_add}) + + +@staff_member_required +def delete_comment(request, comment_id, show_add): + show_add = show_add == 'true' + Comment.objects.filter(id=comment_id).delete() + return render(request, 'usethesource/comment_deleted.html', {'comment': None, 'add': show_add}) + + +@staff_member_required +def add_button(request, slug): + candidate = get_object_or_404(Candidate, slug=slug) + return render(request, 'usethesource/add_comment_button_partial.html', {'candidate': candidate})