Compare commits

...

10 commits

Author SHA1 Message Date
3bbd987e35 usethesource: Add link to offer upload 2024-07-30 13:03:48 +10:00
54ae2c7b06 usethesource: Add admin interface 2024-07-29 23:44:40 +10:00
b39fbaa402 usethesource: Add missing template 2024-07-29 23:33:58 +10:00
770f4f6c26 usethesource: Add prototype "upload offer for source" 2024-07-29 23:29:25 +10:00
a2f38653fb
Fix trailing whitespace and missing end-of-file newline 2024-07-22 18:39:05 +10:00
c5289f39bb
Fix flake8 warnings 2024-07-22 18:39:00 +10:00
1a5441ba75
Fix tests
These were failing due to pytest defaulting to `DEBUG = False` (and then getting
`ValueError: Missing staticfiles manifest entry for ...`).
2024-07-22 16:56:25 +10:00
6636119200
Use {% static %} for home page video subtitles 2024-07-22 10:37:56 +10:00
02efd52c48
Update README - overview, licenses and Ansible 2024-07-22 10:32:41 +10:00
5479785cc1
Add default line-height to <li> elements
They currently look too tight compared with paragraph text.
2024-07-22 10:13:50 +10:00
73 changed files with 379 additions and 223 deletions

View file

@ -1,5 +1,9 @@
# Software Freedom Conservancy website # Software Freedom Conservancy website
This is a Python/[Django](https://www.djangoproject.com/)-based website that
runs [sfconservancy.org](https://sfconservancy.org).
## Contributing ## Contributing
The canonical location for this repository is [on Conservancys The canonical location for this repository is [on Conservancys
@ -9,9 +13,9 @@ Kallithea instance](https://k.sfconservancy.org/website).
## License ## License
The software included herein, such as the Python source files, are generally The software included herein, such as the Python source files, are generally
licensed [AGPLv3](AGPLv3)-or-later. The Javascript is a hodgepodge of licensed [AGPLv3](AGPLv3)-or-later. JavaScript source is generally
licensing, but all of it is compatible with [AGPLv3](AGPLv3)-or-later. See [GPLv3](GPLv3)-or-later. See the notices at the top of each Javascript file for
the notices at the top of each Javascript file for licensing details. specific licensing details.
The content and text (such as the HTML files) is currently The content and text (such as the HTML files) is currently
[CC-BY-SA-3.0](CC-By-SA-3.0). [CC-BY-SA-3.0](CC-By-SA-3.0).
@ -20,7 +24,7 @@ The content and text (such as the HTML files) is currently
## Server configuration ## Server configuration
Conservancy's webserver runs on a standard Debian installation. For Conservancy's webserver runs on a standard Debian installation. For
configuration requirements, see `deploy/ansible/install.yml`. configuration requirements, see `deploy/install.yml`.
## CDN ## CDN

6
bin/test Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -e # Abort on failure
set -x
python3 -m pytest

View file

@ -17,5 +17,3 @@ class EntryAdmin(admin.ModelAdmin):
search_fields = ['headline', 'summary', 'body'] search_fields = ['headline', 'summary', 'body']
prepopulated_fields = {'slug': ("headline",)} prepopulated_fields = {'slug': ("headline",)}
filter_horizontal = ('tags',) filter_horizontal = ('tags',)

View file

@ -72,11 +72,11 @@ class Entry(models.Model, bsoup.SoupModelMixin):
# Ping Technorati # Ping Technorati
j = xmlrpc.client.Server('http://rpc.technorati.com/rpc/ping') j = xmlrpc.client.Server('http://rpc.technorati.com/rpc/ping')
reply = j.weblogUpdates.ping(blog_name, blog_url) j.weblogUpdates.ping(blog_name, blog_url)
# Ping Google Blog Search # Ping Google Blog Search
j = xmlrpc.client.Server('http://blogsearch.google.com/ping/RPC2') j = xmlrpc.client.Server('http://blogsearch.google.com/ping/RPC2')
reply = j.weblogUpdates.ping(blog_name, blog_url, post_url) j.weblogUpdates.ping(blog_name, blog_url, post_url)
# Call any superclass's method # Call any superclass's method
super().save() super().save()

View file

@ -3,7 +3,6 @@ from functools import reduce
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.views.generic import ListView
from django.views.generic.dates import ( from django.views.generic.dates import (
DateDetailView, DateDetailView,
DayArchiveView, DayArchiveView,
@ -12,7 +11,7 @@ from django.views.generic.dates import (
) )
from ..staff.models import Person from ..staff.models import Person
from .models import Entry, EntryTag from .models import EntryTag
def OR_filter(field_name, objs): def OR_filter(field_name, objs):

View file

@ -46,10 +46,12 @@ def run(cmd, encoding=None, ok_exitcodes=frozenset([0]), **kwargs):
no_data = '' no_data = ''
with contextlib.ExitStack() as exit_stack: with contextlib.ExitStack() as exit_stack:
proc = exit_stack.enter_context(subprocess.Popen(cmd, **kwargs)) proc = exit_stack.enter_context(subprocess.Popen(cmd, **kwargs))
pipes = [exit_stack.enter_context(open( pipes = [
getattr(proc, name).fileno(), mode, encoding=encoding, closefd=False)) exit_stack.enter_context(open(
for name in ['stdout', 'stderr'] getattr(proc, name).fileno(), mode, encoding=encoding, closefd=False))
if kwargs.get(name) is subprocess.PIPE] for name in ['stdout', 'stderr']
if kwargs.get(name) is subprocess.PIPE
]
if pipes: if pipes:
yield (proc, *pipes) yield (proc, *pipes)
else: else:
@ -88,6 +90,7 @@ class GitPath:
def _cache(orig_func): def _cache(orig_func):
attr_name = '_cached_' + orig_func.__name__ attr_name = '_cached_' + orig_func.__name__
@functools.wraps(orig_func) @functools.wraps(orig_func)
def cache_wrapper(self): def cache_wrapper(self):
try: try:
@ -353,4 +356,3 @@ def main(arglist=None, stdout=sys.stdout, stderr=sys.stderr):
if __name__ == '__main__': if __name__ == '__main__':
exit(main()) exit(main())

View file

@ -16,6 +16,3 @@ class EventAdmin(admin.ModelAdmin):
@admin.register(EventMedia) @admin.register(EventMedia)
class EventMediaAdmin(admin.ModelAdmin): class EventMediaAdmin(admin.ModelAdmin):
list_display = ("event", "format", "novel") list_display = ("event", "format", "novel")

View file

@ -96,4 +96,3 @@ class EventMedia(models.Model):
def __str__(self): def __str__(self):
return "{} media: {}".format(self.event, self.format) return "{} media: {}".format(self.event, self.format)

View file

@ -13,9 +13,11 @@ from .news.models import PressRelease
class ConservancyFeedBase(Feed): class ConservancyFeedBase(Feed):
def copyright_holder(self): return "Software Freedom Conservancy" def copyright_holder(self):
return "Software Freedom Conservancy"
def license_no_html(self): return "Licensed under the Creative Commons Attribution-ShareAlike 3.0 Unported License." def license_no_html(self):
return "Licensed under the Creative Commons Attribution-ShareAlike 3.0 Unported License."
def item_copyright(self, item): def item_copyright(self, item):
year = 2008 year = 2008
@ -89,7 +91,8 @@ class OmnibusFeed(ConservancyFeedBase):
def item_description(self, item): def item_description(self, item):
return item.summary return item.summary
def item_enclosure_mime_type(self): return "audio/mpeg" def item_enclosure_mime_type(self):
return "audio/mpeg"
def item_enclosure_url(self, item): def item_enclosure_url(self, item):
if hasattr(item, 'mp3_path'): if hasattr(item, 'mp3_path'):
@ -98,9 +101,6 @@ class OmnibusFeed(ConservancyFeedBase):
if hasattr(item, 'mp3_path'): if hasattr(item, 'mp3_path'):
return item.mp3_length return item.mp3_length
def item_pubdate(self, item):
return item.pub_date
def item_author_name(self, item): def item_author_name(self, item):
if item.omnibus_type == "blog": if item.omnibus_type == "blog":
return item.author.formal_name return item.author.formal_name
@ -174,7 +174,8 @@ class BlogFeed(ConservancyFeedBase):
firstTime = True firstTime = True
done = {} done = {}
for tag in tags: for tag in tags:
if tag in done: continue if tag in done:
continue
if firstTime: if firstTime:
answer += " (" answer += " ("
firstTime = False firstTime = False
@ -192,8 +193,10 @@ class BlogFeed(ConservancyFeedBase):
GET = obj.GET GET = obj.GET
tags = [] tags = []
if 'author' in GET: tags = GET.getlist('author') if 'author' in GET:
if 'tag' in GET: tags += GET.getlist('tag') tags = GET.getlist('author')
if 'tag' in GET:
tags += GET.getlist('tag')
done = {} done = {}
if len(tags) == 1: if len(tags) == 1:
@ -201,7 +204,8 @@ class BlogFeed(ConservancyFeedBase):
elif len(tags) > 1: elif len(tags) > 1:
firstTime = True firstTime = True
for tag in tags: for tag in tags:
if tag in done: continue if tag in done:
continue
if firstTime: if firstTime:
answer += " tagged with " answer += " tagged with "
firstTime = False firstTime = False

View file

@ -19,7 +19,3 @@ class ExternalArticleAdmin(admin.ModelAdmin):
list_filter = ['date'] list_filter = ['date']
date_hierarchy = 'date' date_hierarchy = 'date'
search_fields = ["title", "info", "publication"] search_fields = ["title", "info", "publication"]

View file

@ -34,8 +34,10 @@ class PressRelease(models.Model, bsoup.SoupModelMixin):
return self.headline return self.headline
def get_absolute_url(self): def get_absolute_url(self):
return "/news/{}/{}/".format(self.pub_date.strftime("%Y/%b/%d").lower(), return "/news/{}/{}/".format(
self.slug) self.pub_date.strftime("%Y/%b/%d").lower(),
self.slug,
)
def is_recent(self): def is_recent(self):
return self.pub_date > (datetime.now() - timedelta(days=5)) return self.pub_date > (datetime.now() - timedelta(days=5))
@ -60,11 +62,11 @@ class PressRelease(models.Model, bsoup.SoupModelMixin):
# Ping Technorati # Ping Technorati
j = xmlrpc.client.Server('http://rpc.technorati.com/rpc/ping') j = xmlrpc.client.Server('http://rpc.technorati.com/rpc/ping')
reply = j.weblogUpdates.ping(blog_name, blog_url) j.weblogUpdates.ping(blog_name, blog_url)
# Ping Google Blog Search # Ping Google Blog Search
j = xmlrpc.client.Server('http://blogsearch.google.com/ping/RPC2') j = xmlrpc.client.Server('http://blogsearch.google.com/ping/RPC2')
reply = j.weblogUpdates.ping(blog_name, blog_url, post_url) j.weblogUpdates.ping(blog_name, blog_url, post_url)
# Call any superclass's method # Call any superclass's method
super().save() super().save()
@ -114,4 +116,3 @@ class ExternalArticle(models.Model):
objects = models.Manager() objects = models.Manager()
public = PublicExternalArticleManager() public = PublicExternalArticleManager()

View file

@ -1,7 +1,6 @@
from datetime import datetime from datetime import datetime
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.dates import ( from django.views.generic.dates import (
@ -11,8 +10,7 @@ from django.views.generic.dates import (
YearArchiveView, YearArchiveView,
) )
from ..events.models import Event from .models import PressRelease
from .models import ExternalArticle, PressRelease
class NewsListView(ListView): class NewsListView(ListView):
@ -27,8 +25,9 @@ def listing(request, *args, **kwargs):
news_queryset = PressRelease.objects.all() news_queryset = PressRelease.objects.all()
# if (not kwargs.has_key('allow_future')) or not kwargs['allow_future']: # if (not kwargs.has_key('allow_future')) or not kwargs['allow_future']:
news_queryset = news_queryset.filter(**{'%s__lte' % kwargs['date_field']: news_queryset = news_queryset.filter(
datetime.now()}) **{'%s__lte' % kwargs['date_field']: datetime.now()}
)
date_list = news_queryset.dates(kwargs['date_field'], 'year') date_list = news_queryset.dates(kwargs['date_field'], 'year')
@ -93,4 +92,3 @@ class NewsDateDetailView(DateDetailView):
# # del kwargs['slug'] # # del kwargs['slug']
# callable = NewsDateDetailView.as_view(**kwargs) # callable = NewsDateDetailView.as_view(**kwargs)
# return callable(request) # return callable(request)

View file

@ -39,5 +39,3 @@ class CastAdmin(admin.ModelAdmin):
search_fields = ['title', 'summary', 'body'] search_fields = ['title', 'summary', 'body']
prepopulated_fields = {'slug': ("title",)} prepopulated_fields = {'slug': ("title",)}
filter_horizontal = ('tags',) filter_horizontal = ('tags',)

View file

@ -19,7 +19,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.db import models from django.db import models
from django.urls import reverse
class Podcast(models.Model): class Podcast(models.Model):

View file

@ -1,4 +1,4 @@
from .base import * from .base import * # NOQA
DEBUG = True DEBUG = True
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']

View file

@ -2,7 +2,7 @@ import json
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from .base import * from .base import * # NOQA
DEBUG = False DEBUG = False
ALLOWED_HOSTS = ['www.sfconservancy.org', 'sfconservancy.org'] ALLOWED_HOSTS = ['www.sfconservancy.org', 'sfconservancy.org']
@ -25,7 +25,7 @@ DATABASES = {
# Apache/mod_wsgi doesn't make it straightforward to pass environment variables # Apache/mod_wsgi doesn't make it straightforward to pass environment variables
# to Django (can't use the Apache config). # to Django (can't use the Apache config).
with open(BASE_DIR.parent / 'secrets.json') as f: with open(BASE_DIR.parent / 'secrets.json') as f: # NOQA
secrets = json.load(f) secrets = json.load(f)
def get_secret(secrets, setting): def get_secret(secrets, setting):

View file

@ -8,4 +8,3 @@ class PersonAdmin(admin.ModelAdmin):
list_display = ("username", "formal_name", "casual_name", list_display = ("username", "formal_name", "casual_name",
"currently_employed") "currently_employed")
list_filter = ["currently_employed"] list_filter = ["currently_employed"]

View file

@ -8,7 +8,7 @@ p, h1, h2, h3, h4, h5, h6, #mainContent ul, #mainContent ol {
margin-bottom: 1em; margin-bottom: 1em;
} }
p { p, li {
line-height: 1.6; line-height: 1.6;
} }

File diff suppressed because one or more lines are too long

View file

@ -4434,4 +4434,3 @@ being here with Ken thanks so much
01:00:28.380 --> 01:00:31.380 01:00:28.380 --> 01:00:31.380
foreign foreign

View file

@ -202,4 +202,3 @@ we request
00:03:38.680 --> 00:03:44.200 00:03:38.680 --> 00:03:44.200
that you grant Karen Sandler that you grant Karen Sandler
the KU Leuven honorary doctorate. the KU Leuven honorary doctorate.

View file

@ -214,4 +214,3 @@ op voordracht van de Academische Raad,
00:03:38.680 --> 00:03:44.200 00:03:38.680 --> 00:03:44.200
het eredoctoraat van de KU Leuven het eredoctoraat van de KU Leuven
te verlenen aan mevrouw Karen Sandler. te verlenen aan mevrouw Karen Sandler.

View file

@ -6,4 +6,3 @@ from .models import Supporter
@admin.register(Supporter) @admin.register(Supporter)
class SupporterAdmin(admin.ModelAdmin): class SupporterAdmin(admin.ModelAdmin):
list_display = ('display_name', 'display_until_date') list_display = ('display_name', 'display_until_date')

View file

@ -52,7 +52,7 @@ strategies that defend FOSS (such as copyleft). <a href="/about" class="orange">
<section class="mh0 pa3 bg-light-blue ba b--gray"> <section class="mh0 pa3 bg-light-blue ba b--gray">
<video style="height: auto;" controls="" poster="/videos/sfc-introduction-video_poster.jpg"> <video style="height: auto;" controls="" poster="/videos/sfc-introduction-video_poster.jpg">
<source src="/videos/sfc-introduction_1080p.mp4" /> <source src="/videos/sfc-introduction_1080p.mp4" />
<track src="/docs/sfc-introduction-vtt-captions.txt" kind="subtitles" srclang="en" label="English" /> <track src="{% static 'docs/sfc-introduction-vtt-captions.txt' %}" kind="subtitles" srclang="en" label="English" />
<a href="/videos/sfc-introduction_1080p.mp4"><img src="/videos/sfc-introduction-video_poster.jpg" alt="Software Freedom Conservancy introduction video"></a><br/> <a href="/videos/sfc-introduction_1080p.mp4"><img src="/videos/sfc-introduction-video_poster.jpg" alt="Software Freedom Conservancy introduction video"></a><br/>
<a href="https://youtu.be/yCCxMfW0LTM">(watch on Youtube)</a> <a href="https://youtu.be/yCCxMfW0LTM">(watch on Youtube)</a>
</video> </video>

View file

@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from .emails import make_candidate_email from .emails import make_candidate_email
from .models import Candidate, Comment from .models import Candidate, Comment, SourceOffer
class CommentInline(admin.TabularInline): class CommentInline(admin.TabularInline):
@ -36,3 +36,10 @@ class CandidateAdmin(admin.ModelAdmin):
# Announce the new candidate # Announce the new candidate
email = make_candidate_email(obj, request.user) email = make_candidate_email(obj, request.user)
email.send() email.send()
@admin.register(SourceOffer)
class SourceOfferAdmin(admin.ModelAdmin):
list_display = ['time', 'vendor', 'device']
fields = ['time', 'vendor', 'device', 'photo']
readonly_fields = ['time']

View file

@ -1,6 +1,6 @@
from django import forms from django import forms
from .models import Comment from .models import Comment, SourceOffer
class CommentForm(forms.ModelForm): class CommentForm(forms.ModelForm):
@ -17,3 +17,14 @@ class CommentForm(forms.ModelForm):
class DownloadForm(forms.Form): class DownloadForm(forms.Form):
agree = forms.BooleanField(label="I understand that the goal of this process is to determine compliance with FOSS licenses, and that in downloading the source code candidate and/or firmware image, I am assisting SFC as a volunteer to investigate that question. I, therefore, promise and represent that I will not copy, distribute, modify, or otherwise use this source code candidate and/or firmware image for any purpose other than to help SFC evaluate the source code candidate for compliance with the terms of FOSS licenses, including but not limited to any version of the GNU General Public License. Naturally, if I determine in good faith that portions of the source code candidate and/or firmware image are subject to a FOSS license and are compliant with it, I may copy, distribute, modify, or otherwise use those portions in accordance with the FOSS license, and I take full responsibility for that determination and subsequent use.") agree = forms.BooleanField(label="I understand that the goal of this process is to determine compliance with FOSS licenses, and that in downloading the source code candidate and/or firmware image, I am assisting SFC as a volunteer to investigate that question. I, therefore, promise and represent that I will not copy, distribute, modify, or otherwise use this source code candidate and/or firmware image for any purpose other than to help SFC evaluate the source code candidate for compliance with the terms of FOSS licenses, including but not limited to any version of the GNU General Public License. Naturally, if I determine in good faith that portions of the source code candidate and/or firmware image are subject to a FOSS license and are compliant with it, I may copy, distribute, modify, or otherwise use those portions in accordance with the FOSS license, and I take full responsibility for that determination and subsequent use.")
class SourceOfferForm(forms.ModelForm):
class Meta:
model = SourceOffer
fields = ['vendor', 'device', 'photo']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['photo'].widget.attrs['capture'] = 'camera'
self.fields['photo'].widget.attrs['accept'] = 'image/*'

View file

@ -0,0 +1,30 @@
# Generated by Django 4.2.11 on 2024-07-22 08:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('usethesource', '0008_comment_attribute_to'),
]
operations = [
migrations.CreateModel(
name='SourceOffer',
fields=[
(
'id',
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
('vendor', models.CharField(max_length=50, verbose_name='Vendor name')),
('device', models.CharField(max_length=50, verbose_name='Device name')),
('photo', models.ImageField(upload_to='usethesource/offers')),
],
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2024-07-29 09:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('usethesource', '0009_sourceoffer'),
]
operations = [
migrations.AddField(
model_name='sourceoffer',
name='time',
field=models.DateTimeField(auto_now_add=True, null=True),
),
]

View file

@ -67,3 +67,13 @@ class Comment(models.Model):
class Meta: class Meta:
ordering = ['id'] ordering = ['id']
class SourceOffer(models.Model):
time = models.DateTimeField(auto_now_add=True, null=True)
vendor = models.CharField('Vendor name', max_length=50)
device = models.CharField('Device name', max_length=50)
photo = models.ImageField(upload_to='usethesource/offers')
def __str__(self):
return f'{self.vendor} {self.device}'

View file

@ -24,7 +24,7 @@
<p>One crucial way to get involved is to let us know about any source candidates you find! Many devices have an offer for source code (check the manual or device's user interface to find it) and we'd be very interested to know what they send you when you request it. Here are the steps to submit a new source candidate to list on this page:</p> <p>One crucial way to get involved is to let us know about any source candidates you find! Many devices have an offer for source code (check the manual or device's user interface to find it) and we'd be very interested to know what they send you when you request it. Here are the steps to submit a new source candidate to list on this page:</p>
<ol class="pl4"> <ol class="pl4">
<li class="mb2">find a source candidate offered by a company - normally this is offered to you in the manual or user interface of your device, through a link or email address (the company's GitHub page is not canonical, unless they explicitly say so in this offer)</li> <li class="mb2">find a source candidate offered by a company - normally this is offered to you in the manual or user interface of your device, through a link or email address (the company's GitHub page is not canonical, unless they explicitly say so in this offer). If you're curious what an offer is, check out the PDFs referenced in <a href="https://sfconservancy.org/blog/2022/dec/21/energyguide-software-repair-label/">our submission to the FTC</a>, and <a href="{% url 'usethesource:upload_offer' %}">submit a picture/image of a new offer</a> so we can test it for you if you like</li>
<li class="mb2"><a href="https://usl-upload.sfconservancy.org/s/4Ykmx7rSGMJ7s43">upload the source candidate</a> to us - write down the file name(s) you uploaded for the next step (can be multiple), and upload a firmware image if you have it and are ok with us publishing it</li> <li class="mb2"><a href="https://usl-upload.sfconservancy.org/s/4Ykmx7rSGMJ7s43">upload the source candidate</a> to us - write down the file name(s) you uploaded for the next step (can be multiple), and upload a firmware image if you have it and are ok with us publishing it</li>

View file

@ -0,0 +1,49 @@
{% extends "usethesource/base.html" %}
{% block title %}Upload an offer for source - Software Freedom Conservancy{% endblock %}
{% block head %}
{{ block.super }}
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
{% endblock %}
{% block content %}
{{ block.super }}
<section class="mt4 mb3">
<h2 class="f2 lh-title ttu mt0">Upload an offer for source</h2>
</section>
<form id="form" hx-encoding="multipart/form-data" hx-post="{% url 'usethesource:upload_offer' %}">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="mv2">
{{ form.vendor.errors }}
<label for="{{ form.vendor.id_for_label }}" class="db mb1">Vendor:</label>
{{ form.vendor }}
</div>
<div class="mv2">
{{ form.device.errors }}
<label for="{{ form.device.id_for_label }}" class="db mb1">Device:</label>
{{ form.device }}
</div>
<div class="mv2">
{{ form.photo.errors }}
<label for="{{ form.photo.id_for_label }}" class="db mb1">Photo:</label>
{{ form.photo }}
</div>
<progress id="progress" class="htmx-indicator" value="0" max="100"></progress>
<div class="mv1">
<button type="submit" class="white bg-green b db pv2 ph3 bn mb2">Send</button>
</div>
</form>
<script>
form = document.querySelector('#form');
let progress = document.querySelector('#progress');
form.addEventListener('htmx:xhr:progress', function(evt) {
console.log('progress', evt.detail.loaded/evt.detail.total * 100);
progress.value = evt.detail.loaded/evt.detail.total * 100;
});
</script>
{% endblock content %}

View file

@ -0,0 +1 @@
<p>Thanks! We've received your offer for source.</p>

View file

@ -13,4 +13,5 @@ urlpatterns = [
path('delete-comment/<int:comment_id>/<show_add>/', views.delete_comment, name='delete_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'), path('add-button/<slug:slug>/', views.add_button, name='add_button'),
path('ccirt-process/', views.ccirt_process, name='ccirt_process'), path('ccirt-process/', views.ccirt_process, name='ccirt_process'),
path('offer/', views.upload_offer, name='upload_offer'),
] ]

View file

@ -3,7 +3,7 @@ from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from .models import Candidate, Comment from .models import Candidate, Comment
from .forms import CommentForm, DownloadForm from .forms import CommentForm, DownloadForm, SourceOfferForm
from .emails import make_comment_email from .emails import make_comment_email
@ -91,3 +91,21 @@ def add_button(request, slug):
def ccirt_process(request): def ccirt_process(request):
return render(request, 'usethesource/ccirt_process.html', {}) return render(request, 'usethesource/ccirt_process.html', {})
def handle_uploaded_file(f):
with open("some/file/name.txt", "wb+") as destination:
for chunk in f.chunks():
destination.write(chunk)
def upload_offer(request):
if request.method == 'POST':
form = SourceOfferForm(request.POST, request.FILES)
if form.is_valid():
form.save()
return render(request, 'usethesource/upload_success_partial.html')
else:
return render(request, 'usethesource/upload_offer.html', {'form': form})
else:
form = SourceOfferForm()
return render(request, 'usethesource/upload_offer.html', {'form': form})

View file

@ -6,4 +6,3 @@ from .models import EarthLocation
@admin.register(EarthLocation) @admin.register(EarthLocation)
class EarthLocationAdmin(admin.ModelAdmin): class EarthLocationAdmin(admin.ModelAdmin):
list_display = ("label", "html_map_link") list_display = ("label", "html_map_link")

View file

@ -26,4 +26,3 @@ class EarthLocation(models.Model):
def html_map_link(self): # for Admin, fixme: fix_ampersands def html_map_link(self): # for Admin, fixme: fix_ampersands
return '<a href="%s">map link</a>' % self.default_map_link() return '<a href="%s">map link</a>' % self.default_map_link()
html_map_link.allow_tags = True html_map_link.allow_tags = True

18
pyproject.toml Normal file
View file

@ -0,0 +1,18 @@
[tool.black]
skip-string-normalization = true
line-length = 90
[tool.isort]
profile = "black"
force_sort_within_sections = true
line_length = 90
sections = "FUTURE,STDLIB,THIRDPARTY,LOCALFOLDER,FIRSTPARTY"
no_lines_before = "FIRSTPARTY"
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = 'conservancy.settings.dev'
python_files = ['test*.py']
# pytest-django will default to running tests with DEBUG = False, regardless of
# the settings you provide it. This fails due to the
# `ManifestStaticFilesStorage` without explicitly running `collectstatic` first.
django_debug_mode = true