usethesource: Add data models, admin and frontend comment editing

This commit is contained in:
Ben Sturmfels 2023-10-24 23:55:14 +11:00
parent 905e5c918d
commit cc3224bb60
Signed by: bsturmfels
GPG key ID: 023C05E2C9C068F0
19 changed files with 251 additions and 40 deletions

View file

@ -100,7 +100,7 @@ INSTALLED_APPS = [
'conservancy.apps.assignment',
'conservancy.apps.fossy',
'podjango',
'usethesource',
'usethesource.apps.UseTheSourceConfig',
]
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'

26
www/usethesource/admin.py Normal file
View file

@ -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']}

6
www/usethesource/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class UseTheSourceConfig(AppConfig):
default_auto_field = 'django.db.models.AutoField'
name = 'usethesource'

View file

@ -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'],
},
),
]

View file

View file

@ -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']

View file

@ -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>

View file

@ -0,0 +1 @@
<button type="submit" class="b white bg-light-silver pv2 ph3" style="border: none">Cancel</button>

View file

@ -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 %}

View file

@ -0,0 +1,3 @@
{% if add and user.is_staff %}
{% include 'usethesource/add_comment_button_partial.html' %}
{% endif %}

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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>

View file

@ -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 %}

View file

@ -0,0 +1,5 @@
{% include 'usethesource/comment_partial.html' %}
{% if add and user.is_staff %}
{% include 'usethesource/add_comment_button_partial.html' %}
{% endif %}

View file

@ -0,0 +1 @@
<button type="submit" class="b white bg-green pv2 ph3" style="border: none">Save</button>

View file

@ -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'),
]

View file

@ -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})