chore: Run formatters and fix lint errors

This commit is contained in:
Tom Hubrecht 2024-10-23 11:06:57 +02:00
parent 09ad2ca896
commit 430b3b40bb
Signed by: thubrecht
SSH key fingerprint: SHA256:r+nK/SIcWlJ0zFZJGHtlAoRwq1Rm+WcKAm5ADYMoQPc
35 changed files with 415 additions and 284 deletions

View file

@ -1,11 +1,12 @@
from django.contrib import admin from django.contrib import admin
from . import models from . import models
@admin.register(models.ApiKey) @admin.register(models.ApiKey)
class ApiKeyAdmin(admin.ModelAdmin): class ApiKeyAdmin(admin.ModelAdmin):
list_display = ['name', 'last_used', 'displayValue'] list_display = ["name", "last_used", "displayValue"]
readonly_fields = ['keyId', 'key', 'last_used', 'displayValue'] readonly_fields = ["keyId", "key", "last_used", "displayValue"]
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if not change: if not change:

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class ApiConfig(AppConfig): class ApiConfig(AppConfig):
name = 'api' name = "api"

View file

@ -1,86 +1,93 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
''' API client for bocal-site ''' """ API client for bocal-site """
import json
import urllib.request
import hmac
import hashlib
from datetime import datetime, date
import argparse import argparse
import hashlib
import hmac
import json
import os.path import os.path
import sys import sys
import urllib.request
from datetime import date, datetime
def sendReq(url): def sendReq(url):
def send(payload, host): def send(payload, host):
try: try:
req = urllib.request.Request('https://{}/{}'.format(host, url), req = urllib.request.Request(
json.dumps(payload).encode('ascii')) "https://{}/{}".format(host, url), json.dumps(payload).encode("ascii")
req.add_header('Content-Type', 'application/json') )
req.add_header("Content-Type", "application/json")
handle = urllib.request.urlopen(req) handle = urllib.request.urlopen(req)
code = handle.getcode() code = handle.getcode()
content = handle.read() content = handle.read()
handle.close() handle.close()
return (code, content.decode('utf-8')) return (code, content.decode("utf-8"))
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
return (e.code, e.read().decode('utf-8')) return (e.code, e.read().decode("utf-8"))
def authentify(apiKey, payload): def authentify(apiKey, payload):
keyId, key = apiKey.split('$') keyId, key = apiKey.split("$")
keyId = int(keyId) keyId = int(keyId)
time = datetime.now().timestamp() time = datetime.now().timestamp()
mac = hmac.new(key.encode('utf-8'), mac = hmac.new(
msg=str(int(time)).encode('utf-8'), key.encode("utf-8"),
digestmod=hashlib.sha256) msg=str(int(time)).encode("utf-8"),
digestmod=hashlib.sha256,
)
payload_enc = json.dumps(payload) payload_enc = json.dumps(payload)
mac.update(payload_enc.encode('utf-8')) mac.update(payload_enc.encode("utf-8"))
auth = { auth = {
'keyId': keyId, "keyId": keyId,
'timestamp': time, "timestamp": time,
'hmac': mac.hexdigest(), "hmac": mac.hexdigest(),
} }
return { return {
'auth': auth, "auth": auth,
'req': payload_enc, "req": payload_enc,
} }
def decorator(fct): def decorator(fct):
''' Decorator. Adds authentication layer. ''' """Decorator. Adds authentication layer."""
def wrap(host, apiKey, *args, **kwargs): def wrap(host, apiKey, *args, **kwargs):
innerReq = fct(*args, **kwargs) innerReq = fct(*args, **kwargs)
payload = authentify(apiKey, innerReq) payload = authentify(apiKey, innerReq)
return send(payload, host) return send(payload, host)
return wrap return wrap
return decorator return decorator
@sendReq(url='api/publish') @sendReq(url="api/publish")
def publish(bocId, url, date): def publish(bocId, url, date):
return { return {
'id': bocId, "id": bocId,
'url': url, "url": url,
'date': date.strftime('%Y-%m-%d'), "date": date.strftime("%Y-%m-%d"),
} }
############################################################################### ###############################################################################
TOKEN_DFT_FILE = os.path.expanduser("~/.bocal_api_token") TOKEN_DFT_FILE = os.path.expanduser("~/.bocal_api_token")
DFT_HOST = 'bocal.cof.ens.fr' DFT_HOST = "bocal.cof.ens.fr"
def read_token(path): def read_token(path):
token = '' token = ""
try: try:
with open(path, 'r') as handle: with open(path, "r") as handle:
token = handle.readline().strip() token = handle.readline().strip()
except FileNotFoundError: except FileNotFoundError:
print("[Erreur] Fichier d'identifiants absent (`{}`).".format(path), print(
file=sys.stderr) "[Erreur] Fichier d'identifiants absent (`{}`).".format(path),
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
return token return token
@ -89,6 +96,7 @@ def cmd(func):
def wrap(parse_args, *args, **kwargs): def wrap(parse_args, *args, **kwargs):
token = read_token(parse_args.creds) token = read_token(parse_args.creds)
return func(token, parse_args, *args, **kwargs) return func(token, parse_args, *args, **kwargs)
return wrap return wrap
@ -97,13 +105,9 @@ def cmd_publish(token, args):
if not args.date: if not args.date:
publish_date = date.today() publish_date = date.today()
else: else:
year, month, day = [int(x) for x in args.date.strip().split('-')] year, month, day = [int(x) for x in args.date.strip().split("-")]
publish_date = date(year=year, month=month, day=day) publish_date = date(year=year, month=month, day=day)
(ret_code, ret_str) = publish(args.host, (ret_code, ret_str) = publish(args.host, token, args.numero, args.url, publish_date)
token,
args.numero,
args.url,
publish_date)
if ret_code == 200: if ret_code == 200:
print("Succès :)") print("Succès :)")
else: else:
@ -113,29 +117,30 @@ def cmd_publish(token, args):
def setup_argparse(): def setup_argparse():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('--host', parser.add_argument(
help=("Adresse du site à contacter (par défaut, " "--host",
"`{}`).".format(DFT_HOST))) help=("Adresse du site à contacter (par défaut, " "`{}`).".format(DFT_HOST)),
parser.add_argument('--creds', )
help=("Fichier contenant le token API à utiliser " parser.add_argument(
"(par défaut, `{}`)".format(TOKEN_DFT_FILE))) "--creds",
help=(
"Fichier contenant le token API à utiliser "
"(par défaut, `{}`)".format(TOKEN_DFT_FILE)
),
)
parser.set_defaults(creds=TOKEN_DFT_FILE, host=DFT_HOST) parser.set_defaults(creds=TOKEN_DFT_FILE, host=DFT_HOST)
subparsers = parser.add_subparsers() subparsers = parser.add_subparsers()
parser_publish = subparsers.add_parser('publier', parser_publish = subparsers.add_parser("publier", help="Publier un numéro du BOcal")
help='Publier un numéro du BOcal') parser_publish.add_argument("numero", help="Numéro du BOcal")
parser_publish.add_argument('numero', parser_publish.add_argument("url", help="Adresse (locale) du PDF du BOcal")
help='Numéro du BOcal') parser_publish.add_argument("-d", "--date", help="Date de publication indiquée")
parser_publish.add_argument('url',
help='Adresse (locale) du PDF du BOcal')
parser_publish.add_argument('-d', '--date',
help="Date de publication indiquée")
parser_publish.set_defaults(func=cmd_publish) parser_publish.set_defaults(func=cmd_publish)
out_args = parser.parse_args() out_args = parser.parse_args()
if 'func' not in out_args: # No subcommand provided if "func" not in out_args: # No subcommand provided
print("You must provide a command.", file=sys.stderr) print("You must provide a command.", file=sys.stderr)
print(parser.parse_args(['-h']), file=sys.stderr) print(parser.parse_args(["-h"]), file=sys.stderr)
sys.exit(1) sys.exit(1)
return out_args return out_args
@ -145,5 +150,5 @@ def main():
args.func(args) args.func(args)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View file

@ -3,6 +3,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import datetime import datetime
from django.db import migrations, models from django.db import migrations, models
@ -10,17 +11,37 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='ApiKey', name="ApiKey",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('key', models.CharField(max_length=128, verbose_name='API key')), "id",
('name', models.CharField(help_text='Where is this key used from?', max_length=256, verbose_name='Key name')), models.AutoField(
('last_used', models.DateTimeField(default=datetime.datetime(1970, 1, 1, 1, 0), verbose_name='Last used')), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(max_length=128, verbose_name="API key")),
(
"name",
models.CharField(
help_text="Where is this key used from?",
max_length=256,
verbose_name="Key name",
),
),
(
"last_used",
models.DateTimeField(
default=datetime.datetime(1970, 1, 1, 1, 0),
verbose_name="Last used",
),
),
], ],
), ),
] ]

View file

@ -1,30 +1,30 @@
from django.db import models
from datetime import datetime, timedelta
import hmac
import hashlib import hashlib
import hmac
import random import random
import string import string
from datetime import datetime, timedelta
from django.db import models
class ApiKey(models.Model): class ApiKey(models.Model):
''' An API key, to login using the API """An API key, to login using the API
An API key consists in a somewhat long chunk of ascii text, *not* An API key consists in a somewhat long chunk of ascii text, *not*
containing any dollar ($) sign. It is saved on the client's machine as containing any dollar ($) sign. It is saved on the client's machine as
a string "keyId$key". a string "keyId$key".
An API token (to authentify a request) is a triplet (ts, kid, hmac) of An API token (to authentify a request) is a triplet (ts, kid, hmac) of
a timestamp `ts`, the key id `kid` and a timestamp `ts`, the key id `kid` and
hmac = `HMAC(key, ts + data, sha256)` hmac = `HMAC(key, ts + data, sha256)`
where `data` is the normalized (`json.dumps(json.loads(...))`) value of where `data` is the normalized (`json.dumps(json.loads(...))`) value of
the data part of the request. the data part of the request.
''' """
key = models.CharField("API key",
max_length=128) key = models.CharField("API key", max_length=128)
name = models.CharField("Key name", name = models.CharField(
max_length=256, "Key name", max_length=256, help_text="Where is this key used from?"
help_text="Where is this key used from?") )
last_used = models.DateTimeField("Last used", last_used = models.DateTimeField("Last used", default=datetime.fromtimestamp(0))
default=datetime.fromtimestamp(0))
def everUsed(self): def everUsed(self):
return self.last_used > datetime.fromtimestamp(0) return self.last_used > datetime.fromtimestamp(0)
@ -33,8 +33,7 @@ class ApiKey(models.Model):
if not self.key: if not self.key:
KEY_SIZE = 64 KEY_SIZE = 64
KEY_CHARS = string.ascii_letters + string.digits KEY_CHARS = string.ascii_letters + string.digits
self.key = ''.join( self.key = "".join([random.choice(KEY_CHARS) for _ in range(KEY_SIZE)])
[random.choice(KEY_CHARS) for _ in range(KEY_SIZE)])
@property @property
def keyId(self): def keyId(self):
@ -52,9 +51,11 @@ class ApiKey(models.Model):
if datetime.now() - timedelta(minutes=5) > claimedDate: if datetime.now() - timedelta(minutes=5) > claimedDate:
return False return False
mac = hmac.new(self.key.encode('utf-8'), mac = hmac.new(
msg=str(int(claimedDate.timestamp())).encode('utf-8'), self.key.encode("utf-8"),
digestmod=hashlib.sha256) msg=str(int(claimedDate.timestamp())).encode("utf-8"),
mac.update(data.encode('utf-8')) digestmod=hashlib.sha256,
)
mac.update(data.encode("utf-8"))
return hmac.compare_digest(mac.hexdigest(), inpMac) return hmac.compare_digest(mac.hexdigest(), inpMac)

View file

@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.

View file

@ -1,8 +1,9 @@
from django.conf.urls import url from django.urls import path
from . import views from . import views
app_name = 'manisite' app_name = "manisite"
urlpatterns = [ urlpatterns = [
url(r'^publish$', views.publishApiView), path("publish", views.publishApiView),
] ]

View file

@ -1,27 +1,30 @@
from django.http import response
from django.core.exceptions import ValidationError
from django.views.decorators.csrf import csrf_exempt
import json
import datetime import datetime
import json
from django.core.exceptions import ValidationError
from django.http import response
from django.views.decorators.csrf import csrf_exempt
import mainsite.models as mainModels
from . import models from . import models
import mainsite.models as mainModels
def authentify(data, payload): def authentify(data, payload):
''' returns whether the request's authentification is correct ''' """returns whether the request's authentification is correct"""
required = ['keyId', 'timestamp', 'hmac'] required = ["keyId", "timestamp", "hmac"]
for field in required: for field in required:
if field not in data: if field not in data:
return response.HttpResponseForbidden( return response.HttpResponseForbidden(
'Missing required field "{}"'.format(field)) 'Missing required field "{}"'.format(field)
)
try: try:
key = models.ApiKey.objects.get(id=data['keyId']) key = models.ApiKey.objects.get(id=data["keyId"])
except models.ApiKey.DoesNotExist: except models.ApiKey.DoesNotExist:
return response.HttpResponseForbidden('Bad authentication') return response.HttpResponseForbidden("Bad authentication")
if not key.isCorrect(data['timestamp'], data['hmac'], payload): if not key.isCorrect(data["timestamp"], data["hmac"], payload):
return response.HttpResponseForbidden('Bad authentication') return response.HttpResponseForbidden("Bad authentication")
def apiView(required=[]): def apiView(required=[]):
@ -29,15 +32,15 @@ def apiView(required=[]):
@csrf_exempt @csrf_exempt
def wrap(request, *args, **kwargs): def wrap(request, *args, **kwargs):
try: try:
data = json.loads(request.body.decode('utf-8')) data = json.loads(request.body.decode("utf-8"))
except TypeError: except TypeError:
return response.HttpResponseBadRequest("Bad packet format") return response.HttpResponseBadRequest("Bad packet format")
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
return response.HttpResponseBadRequest("Bad json") return response.HttpResponseBadRequest("Bad json")
try: try:
authData = data['auth'] authData = data["auth"]
reqDataOrig = data['req'] reqDataOrig = data["req"]
except KeyError: except KeyError:
return response.HttpResponseBadRequest("Bad request format") return response.HttpResponseBadRequest("Bad request format")
@ -51,39 +54,40 @@ def apiView(required=[]):
for field in required: for field in required:
if field not in reqData: if field not in reqData:
return response.HttpResponseBadRequest( return response.HttpResponseBadRequest(
"Missing field {}".format(field)) "Missing field {}".format(field)
)
authVal = authentify(authData, reqDataOrig) authVal = authentify(authData, reqDataOrig)
if authVal is not None: if authVal is not None:
return authVal return authVal
return fct(request, reqData, *args, **kwargs) return fct(request, reqData, *args, **kwargs)
return wrap return wrap
return decorator return decorator
@apiView(required=["id", "url", "date"]) @apiView(required=["id", "url", "date"])
def publishApiView(request, data): def publishApiView(request, data):
''' Publish a BOcal, and create the corresponding year if needed ''' """Publish a BOcal, and create the corresponding year if needed"""
if mainModels.Publication.objects.filter(num=data['id']).count() > 0: if mainModels.Publication.objects.filter(num=data["id"]).count() > 0:
return response.HttpResponseBadRequest( return response.HttpResponseBadRequest(
"Un BOcal du même numéro est déjà présent ! Ajoutez celui-ci à la " "Un BOcal du même numéro est déjà présent ! Ajoutez celui-ci à la "
"main si vous voulez vraiment faire ça.") "main si vous voulez vraiment faire ça."
)
try: try:
year, month, day = [int(x) for x in data['date'].split('-')] year, month, day = [int(x) for x in data["date"].split("-")]
date = datetime.date(year, month, day) date = datetime.date(year, month, day)
except: except Exception:
return response.HttpResponseBadRequest("Bad date") return response.HttpResponseBadRequest("Bad date")
pub = mainModels.Publication(num=data['id'], pub = mainModels.Publication(num=data["id"], url=data["url"], date=date)
url=data['url'],
date=date)
try: try:
pub.full_clean() pub.full_clean()
except ValidationError as e: except ValidationError as e:
return response.HttpResponseBadRequest( return response.HttpResponseBadRequest("Invalid data: {}".format(e))
"Invalid data: {}".format(e))
pub.save() pub.save()
pub.createPubYear() pub.createPubYear()

View file

@ -1 +1 @@
default_app_config = 'bocal_auth.apps.BocalAuthConfig' default_app_config = "bocal_auth.apps.BocalAuthConfig"

View file

@ -1,3 +1 @@
from django.contrib import admin
# Register your models here. # Register your models here.

View file

@ -2,7 +2,7 @@ from django.apps import AppConfig
class BocalAuthConfig(AppConfig): class BocalAuthConfig(AppConfig):
name = 'bocal_auth' name = "bocal_auth"
def ready(self): def ready(self):
from . import signals pass

View file

@ -1,4 +1,5 @@
from django_cas_ng.backends import CASBackend from django_cas_ng.backends import CASBackend
from .models import CasUser from .models import CasUser

View file

@ -2,9 +2,9 @@
# Generated by Django 1.11.5 on 2017-10-14 16:49 # Generated by Django 1.11.5 on 2017-10-14 16:49
from __future__ import unicode_literals from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -12,14 +12,22 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('auth', '0008_alter_user_username_max_length'), ("auth", "0008_alter_user_username_max_length"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='CasUser', name="CasUser",
fields=[ fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), (
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
serialize=False,
to=settings.AUTH_USER_MODEL,
),
),
], ],
), ),
] ]

View file

@ -1,11 +1,8 @@
from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models
class CasUser(models.Model): class CasUser(models.Model):
''' Describes a Django user that was created through CAS ''' """Describes a Django user that was created through CAS"""
user = models.OneToOneField( user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
User,
on_delete=models.CASCADE,
primary_key=True)

View file

@ -1,23 +1,24 @@
''' Reads a .rhosts file ''' """ Reads a .rhosts file """
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from .models import CasUser from .models import CasUser
def hasUser(user, allowed_domains=[]): def hasUser(user, allowed_domains=[]):
''' Check that `user` appears in the rhosts file. """Check that `user` appears in the rhosts file.
If `allowed_domains` is not empty, also checks that the user belongs to one If `allowed_domains` is not empty, also checks that the user belongs to one
of the specified domains. ''' of the specified domains."""
def clearLine(line): def clearLine(line):
line = line.strip() line = line.strip()
hashPos = line.find('#') hashPos = line.find("#")
if hashPos >= 0: if hashPos >= 0:
line = line[:hashPos] line = line[:hashPos]
return line return line
with open(settings.RHOSTS_PATH, 'r') as handle: with open(settings.RHOSTS_PATH, "r") as handle:
for line in handle: for line in handle:
line = clearLine(line) line = clearLine(line)
if not line: if not line:
@ -31,7 +32,7 @@ def hasUser(user, allowed_domains=[]):
if login != user: # Not the ones we're looking for if login != user: # Not the ones we're looking for
continue continue
if domain[:2] != '+@': # Not a valid domain if domain[:2] != "+@": # Not a valid domain
continue continue
domain = domain[2:] domain = domain[2:]
@ -43,16 +44,16 @@ def hasUser(user, allowed_domains=[]):
def default_allowed(user): def default_allowed(user):
return hasUser(user, allowed_domains=['eleves']) return hasUser(user, allowed_domains=["eleves"])
class NoBOcalException(Exception): class NoBOcalException(Exception):
def __str__(): def __str__(self):
return "The BOcal group was not created" return "The BOcal group was not created"
def bocalGroup(): def bocalGroup():
qs = Group.objects.filter(name='BOcal') qs = Group.objects.filter(name="BOcal")
if qs.count() != 1: if qs.count() != 1:
raise NoBOcalException raise NoBOcalException
return qs[0] return qs[0]
@ -80,6 +81,7 @@ def requireCasUser(fct):
if not hasCas(user): if not hasCas(user):
return return
return fct(user, *args, **kwargs) return fct(user, *args, **kwargs)
return wrap return wrap
@ -100,4 +102,5 @@ def forceReevalRhosts(fct):
def wrap(req, *args, **kwargs): def wrap(req, *args, **kwargs):
evalRhostsPrivileges(req.user) evalRhostsPrivileges(req.user)
return fct(req, *args, **kwargs) return fct(req, *args, **kwargs)
return wrap return wrap

View file

@ -1,16 +1,17 @@
from django.dispatch import receiver from django.dispatch import receiver
from django_cas_ng.signals import cas_user_authenticated, cas_user_logout from django_cas_ng.signals import cas_user_authenticated, cas_user_logout
from . import rhosts from . import rhosts
@receiver(cas_user_authenticated) @receiver(cas_user_authenticated)
def onCasLogin(sender, user, **kwargs): def onCasLogin(sender, user, **kwargs):
''' Called upon login of a user through CAS ''' """Called upon login of a user through CAS"""
rhosts.evalRhostsPrivileges(user) rhosts.evalRhostsPrivileges(user)
@receiver(cas_user_logout) @receiver(cas_user_logout)
def onCasLogout(sender, user, **kwargs): def onCasLogout(sender, user, **kwargs):
''' Strip the user from their privileges — in case something goes wrong """Strip the user from their privileges — in case something goes wrong
during the next authentication ''' during the next authentication"""
rhosts.logout(user) rhosts.logout(user)

View file

@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.

View file

@ -1,9 +1,9 @@
from django.shortcuts import render, redirect from urllib.parse import quote as urlquote
from django.urls import reverse
from django.contrib.auth import logout as auth_logout from django.contrib.auth import logout as auth_logout
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from urllib.parse import quote as urlquote from django.urls import reverse
def login(req): def login(req):

View file

@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from solo.admin import SingletonModelAdmin from solo.admin import SingletonModelAdmin
from . import models
from . import models
admin.site.register(models.SiteConfiguration, SingletonModelAdmin) admin.site.register(models.SiteConfiguration, SingletonModelAdmin)
admin.site.register(models.Publication) admin.site.register(models.Publication)

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class MainsiteConfig(AppConfig): class MainsiteConfig(AppConfig):
name = 'mainsite' name = "mainsite"

View file

@ -7,10 +7,11 @@ def sidebar_years(req):
avail_years = models.PublicationYear.objects.all() avail_years = models.PublicationYear.objects.all()
publi_years = [year for year in avail_years if year.publis().count() > 0] publi_years = [year for year in avail_years if year.publis().count() > 0]
num_special_publications = models.Publication.objects\ num_special_publications = models.Publication.objects.filter(
.filter(is_special=True).count() is_special=True
).count()
return { return {
'publication_years': publi_years, "publication_years": publi_years,
'has_special_publications': num_special_publications > 0, "has_special_publications": num_special_publications > 0,
} }

View file

@ -9,20 +9,57 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Publication', name="Publication",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('num', models.CharField(max_length=128, verbose_name='Numéro du BOcal')), "id",
('url', models.CharField(max_length=512, verbose_name='Adresse sur le site')), models.AutoField(
('date', models.DateField(verbose_name='Publication')), auto_created=True,
('is_special', models.BooleanField(default=False, help_text='Numéro du BOcal non-numéroté', verbose_name='Numéro spécial')), primary_key=True,
('descr', models.CharField(blank=True, max_length=512, verbose_name='Description (optionnelle)')), serialize=False,
('custom_name', models.CharField(blank=True, help_text='Vide pour laisser le numéro seulement', max_length=128, verbose_name='Nom customisé')), verbose_name="ID",
),
),
(
"num",
models.CharField(max_length=128, verbose_name="Numéro du BOcal"),
),
(
"url",
models.CharField(
max_length=512, verbose_name="Adresse sur le site"
),
),
("date", models.DateField(verbose_name="Publication")),
(
"is_special",
models.BooleanField(
default=False,
help_text="Numéro du BOcal non-numéroté",
verbose_name="Numéro spécial",
),
),
(
"descr",
models.CharField(
blank=True,
max_length=512,
verbose_name="Description (optionnelle)",
),
),
(
"custom_name",
models.CharField(
blank=True,
help_text="Vide pour laisser le numéro seulement",
max_length=128,
verbose_name="Nom customisé",
),
),
], ],
), ),
] ]

View file

@ -8,19 +8,27 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('mainsite', '0001_initial'), ("mainsite", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='PublicationYear', name="PublicationYear",
fields=[ fields=[
('startYear', models.IntegerField(help_text='Année scolaire à partir du 1/08', primary_key=True, serialize=False, verbose_name='Année de début')), (
('descr', models.TextField(verbose_name="Accroche de l'année")), "startYear",
models.IntegerField(
help_text="Année scolaire à partir du 1/08",
primary_key=True,
serialize=False,
verbose_name="Année de début",
),
),
("descr", models.TextField(verbose_name="Accroche de l'année")),
], ],
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='publication', name="publication",
options={'ordering': ['date']}, options={"ordering": ["date"]},
), ),
] ]

View file

@ -8,20 +8,41 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('mainsite', '0002_auto_20170922_1438'), ("mainsite", "0002_auto_20170922_1438"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='SiteConfiguration', name="SiteConfiguration",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('homepageText', models.TextField(verbose_name="Texte de la page d'accueil (HTML)")), "id",
('writearticleText', models.TextField(verbose_name='Texte de la page « écrire » (HTML)')), models.AutoField(
('email', models.CharField(help_text='Attention au spam…', max_length=128, verbose_name='Adresse de contact du BOcal')), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"homepageText",
models.TextField(verbose_name="Texte de la page d'accueil (HTML)"),
),
(
"writearticleText",
models.TextField(verbose_name="Texte de la page « écrire » (HTML)"),
),
(
"email",
models.CharField(
help_text="Attention au spam…",
max_length=128,
verbose_name="Adresse de contact du BOcal",
),
),
], ],
options={ options={
'verbose_name': 'Configuration du site', "verbose_name": "Configuration du site",
}, },
), ),
] ]

View file

@ -8,13 +8,17 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('mainsite', '0003_siteconfiguration'), ("mainsite", "0003_siteconfiguration"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='publication', model_name="publication",
name='in_year_view_anyway', name="in_year_view_anyway",
field=models.BooleanField(default=False, help_text="Si le numéro est spécial, l'afficher quand même dans la page de l'année correspondante.", verbose_name="Aussi dans l'année"), field=models.BooleanField(
default=False,
help_text="Si le numéro est spécial, l'afficher quand même dans la page de l'année correspondante.",
verbose_name="Aussi dans l'année",
),
), ),
] ]

View file

@ -8,12 +8,12 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('mainsite', '0004_publication_in_year_view_anyway'), ("mainsite", "0004_publication_in_year_view_anyway"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='publicationyear', name="publicationyear",
options={'ordering': ['-startYear']}, options={"ordering": ["-startYear"]},
), ),
] ]

View file

@ -2,30 +2,36 @@
# Generated by Django 1.11.5 on 2017-09-23 16:47 # Generated by Django 1.11.5 on 2017-09-23 16:47
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations
import markdownx.models import markdownx.models
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('mainsite', '0005_auto_20170922_1916'), ("mainsite", "0005_auto_20170922_1916"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='publicationyear', model_name="publicationyear",
name='descr', name="descr",
field=markdownx.models.MarkdownxField(verbose_name="Accroche de l'année (Markdown)"), field=markdownx.models.MarkdownxField(
verbose_name="Accroche de l'année (Markdown)"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='siteconfiguration', model_name="siteconfiguration",
name='homepageText', name="homepageText",
field=markdownx.models.MarkdownxField(verbose_name="Texte de la page d'accueil (Markdown)"), field=markdownx.models.MarkdownxField(
verbose_name="Texte de la page d'accueil (Markdown)"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='siteconfiguration', model_name="siteconfiguration",
name='writearticleText', name="writearticleText",
field=markdownx.models.MarkdownxField(verbose_name='Texte de la page « écrire » (Markdown)'), field=markdownx.models.MarkdownxField(
verbose_name="Texte de la page « écrire » (Markdown)"
),
), ),
] ]

View file

@ -2,21 +2,24 @@
# Generated by Django 1.11.5 on 2017-09-23 16:54 # Generated by Django 1.11.5 on 2017-09-23 16:54
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations
import markdownx.models import markdownx.models
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('mainsite', '0006_auto_20170923_1847'), ("mainsite", "0006_auto_20170923_1847"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='siteconfiguration', model_name="siteconfiguration",
name='specialPublisDescr', name="specialPublisDescr",
field=markdownx.models.MarkdownxField(default='', verbose_name='Texte de la page des publications spéciales (Markdown)'), field=markdownx.models.MarkdownxField(
default="",
verbose_name="Texte de la page des publications spéciales (Markdown)",
),
preserve_default=False, preserve_default=False,
), ),
] ]

View file

@ -8,13 +8,17 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('mainsite', '0007_siteconfiguration_specialpublisdescr'), ("mainsite", "0007_siteconfiguration_specialpublisdescr"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='publication', model_name="publication",
name='unknown_date', name="unknown_date",
field=models.BooleanField(default=False, help_text="La date de publication du BOcal est inconnue parce qu'il est trop vieux. La date indiquée ne servira qu'à le ranger dans une année et à ordonner les BOcals.", verbose_name='Date inconnue'), field=models.BooleanField(
default=False,
help_text="La date de publication du BOcal est inconnue parce qu'il est trop vieux. La date indiquée ne servira qu'à le ranger dans une année et à ordonner les BOcals.",
verbose_name="Date inconnue",
),
), ),
] ]

View file

@ -1,23 +1,20 @@
from django.db import models
from django.db.models import Q
from django.db.models import DateField, \
CharField, \
BooleanField, \
IntegerField
from solo.models import SingletonModel
from markdownx.models import MarkdownxField
import datetime import datetime
from django.db import models
from django.db.models import BooleanField, CharField, DateField, IntegerField, Q
from markdownx.models import MarkdownxField
from solo.models import SingletonModel
class SiteConfiguration(SingletonModel): class SiteConfiguration(SingletonModel):
homepageText = MarkdownxField("Texte de la page d'accueil (Markdown)") homepageText = MarkdownxField("Texte de la page d'accueil (Markdown)")
writearticleText = MarkdownxField("Texte de la page « écrire » (Markdown)") writearticleText = MarkdownxField("Texte de la page « écrire » (Markdown)")
email = CharField("Adresse de contact du BOcal", email = CharField(
max_length=128, "Adresse de contact du BOcal", max_length=128, help_text="Attention au spam…"
help_text="Attention au spam…") )
specialPublisDescr = MarkdownxField("Texte de la page des " specialPublisDescr = MarkdownxField(
"publications spéciales (Markdown)") "Texte de la page des " "publications spéciales (Markdown)"
)
def __str__(self): def __str__(self):
return "Configuration du site" return "Configuration du site"
@ -27,33 +24,40 @@ class SiteConfiguration(SingletonModel):
class Publication(models.Model): class Publication(models.Model):
num = CharField('Numéro du BOcal', max_length=128) num = CharField("Numéro du BOcal", max_length=128)
url = CharField('Adresse sur le site', max_length=512) url = CharField("Adresse sur le site", max_length=512)
# ^ This is not a URLField because we need internal URLS, eg `/static/blah` # ^ This is not a URLField because we need internal URLS, eg `/static/blah`
date = DateField('Publication') date = DateField("Publication")
unknown_date = BooleanField('Date inconnue', unknown_date = BooleanField(
help_text=("La date de publication du BOcal " "Date inconnue",
"est inconnue parce qu'il est " help_text=(
"trop vieux. La date indiquée ne " "La date de publication du BOcal "
"servira qu'à le ranger dans une " "est inconnue parce qu'il est "
"année et à ordonner les BOcals."), "trop vieux. La date indiquée ne "
default=False) "servira qu'à le ranger dans une "
is_special = BooleanField('Numéro spécial', "année et à ordonner les BOcals."
help_text='Numéro du BOcal non-numéroté', ),
default=False) default=False,
)
is_special = BooleanField(
"Numéro spécial", help_text="Numéro du BOcal non-numéroté", default=False
)
in_year_view_anyway = BooleanField( in_year_view_anyway = BooleanField(
"Aussi dans l'année", "Aussi dans l'année",
help_text=("Si le numéro est spécial, l'afficher quand même dans la " help_text=(
"page de l'année correspondante."), "Si le numéro est spécial, l'afficher quand même dans la "
default=False) "page de l'année correspondante."
descr = CharField('Description (optionnelle)', ),
max_length=512, default=False,
blank=True) )
custom_name = CharField('Nom customisé', descr = CharField("Description (optionnelle)", max_length=512, blank=True)
help_text='Vide pour laisser le numéro seulement', custom_name = CharField(
max_length=128, "Nom customisé",
blank=True) help_text="Vide pour laisser le numéro seulement",
max_length=128,
blank=True,
)
class NoPublicationYear(Exception): class NoPublicationYear(Exception):
def __str__(self): def __str__(self):
@ -67,8 +71,8 @@ class Publication(models.Model):
return startYear return startYear
def publicationYear(self): def publicationYear(self):
''' Fetch corresponding publication year """Fetch corresponding publication year
Raise `NoPublicationYear` if there is no such entry ''' Raise `NoPublicationYear` if there is no such entry"""
startYear = self.numericPublicationYear startYear = self.numericPublicationYear
try: try:
return PublicationYear.objects.get(startYear=startYear) return PublicationYear.objects.get(startYear=startYear)
@ -76,11 +80,13 @@ class Publication(models.Model):
raise self.NoPublicationYear raise self.NoPublicationYear
def createPubYear(self): def createPubYear(self):
''' Creates the corresponding publication year if needed. ''' """Creates the corresponding publication year if needed."""
if (PublicationYear.objects if (
.filter(startYear=self.numericPublicationYear).count()) == 0: PublicationYear.objects.filter(
pubYear = PublicationYear(startYear=self.numericPublicationYear, startYear=self.numericPublicationYear
descr='') ).count()
) == 0:
pubYear = PublicationYear(startYear=self.numericPublicationYear, descr="")
pubYear.save() pubYear.save()
return True return True
return False return False
@ -90,50 +96,51 @@ class Publication(models.Model):
return self.custom_name return self.custom_name
elif self.is_special: elif self.is_special:
return self.num return self.num
return 'BOcal n°{}'.format(self.num) return "BOcal n°{}".format(self.num)
@staticmethod @staticmethod
def latest(): def latest():
return Publication.objects.order_by('-date')[0] return Publication.objects.order_by("-date")[0]
class Meta: class Meta:
ordering = ['date'] ordering = ["date"]
class PublicationYear(models.Model): class PublicationYear(models.Model):
startYear = IntegerField('Année de début', startYear = IntegerField(
help_text='Année scolaire à partir du 15/08', "Année de début", help_text="Année scolaire à partir du 15/08", primary_key=True
primary_key=True) )
descr = MarkdownxField("Accroche de l'année (Markdown)") descr = MarkdownxField("Accroche de l'année (Markdown)")
def __str__(self): def __str__(self):
return '{}-{}'.format(self.startYear, self.startYear+1) return "{}-{}".format(self.startYear, self.startYear + 1)
def beg(self): def beg(self):
''' First day of this publication year (incl.) ''' """First day of this publication year (incl.)"""
return datetime.date(self.startYear, 8, 15) return datetime.date(self.startYear, 8, 15)
def end(self): def end(self):
''' Last day of this publication year (excl.) ''' """Last day of this publication year (excl.)"""
return datetime.date(self.startYear + 1, 8, 15) return datetime.date(self.startYear + 1, 8, 15)
def inYear(self, date): def inYear(self, date):
return self.beg() <= date < self.end() return self.beg() <= date < self.end()
def publis(self): def publis(self):
''' List of publications from this year ''' """List of publications from this year"""
return Publication.objects.filter( return Publication.objects.filter(
Q(is_special=False) | Q(in_year_view_anyway=True), Q(is_special=False) | Q(in_year_view_anyway=True),
date__gte=self.beg(), date__gte=self.beg(),
date__lt=self.end()) date__lt=self.end(),
)
@property @property
def url(self): def url(self):
return '/{}/'.format(self) return "/{}/".format(self)
@property @property
def prettyName(self): def prettyName(self):
return '{}{}'.format(self.startYear, self.startYear+1) return "{}{}".format(self.startYear, self.startYear + 1)
class Meta: class Meta:
ordering = ['-startYear'] ordering = ["-startYear"]

View file

@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.

View file

@ -1,4 +1,5 @@
from django.conf.urls import url from django.conf.urls import url
from . import views from . import views
urlpatterns = [ urlpatterns = [

View file

@ -1,38 +1,38 @@
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.http import Http404, HttpResponse
from mainsite.models import Publication, PublicationYear, SiteConfiguration from mainsite.models import Publication, PublicationYear, SiteConfiguration
def robots_view(request): def robots_view(request):
""" Robots.txt view """ """Robots.txt view"""
body = "User-Agent: *\nDisallow: /\nAllow: /$\n" body = "User-Agent: *\nDisallow: /\nAllow: /$\n"
return HttpResponse(body, content_type="text/plain") return HttpResponse(body, content_type="text/plain")
class HomeView(TemplateView): class HomeView(TemplateView):
""" Website's homepage """ """Website's homepage"""
template_name = "mainsite/homepage.html" template_name = "mainsite/homepage.html"
class WriteArticleView(TemplateView): class WriteArticleView(TemplateView):
""" Tell the readers how they can contribute to the BOcal """ """Tell the readers how they can contribute to the BOcal"""
template_name = "mainsite/write_article.html" template_name = "mainsite/write_article.html"
class PublicationListView(TemplateView): class PublicationListView(TemplateView):
""" Display a list of publications (generic class). """Display a list of publications (generic class).
Reimplement `get_publications` (called with the url template args in a Reimplement `get_publications` (called with the url template args in a
place where you can Http404) in subclasses to get it working. """ place where you can Http404) in subclasses to get it working."""
template_name = "mainsite/publications_list_view.html" template_name = "mainsite/publications_list_view.html"
def initView(self): def initView(self):
""" Cannot be __init__, we don't have **kwargs there """ """Cannot be __init__, we don't have **kwargs there"""
pass pass
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -51,7 +51,7 @@ class PublicationListView(TemplateView):
class YearView(PublicationListView): class YearView(PublicationListView):
""" Display a year worth of BOcals """ """Display a year worth of BOcals"""
def initView(self, year, nYear): def initView(self, year, nYear):
try: try:
@ -76,7 +76,7 @@ class YearView(PublicationListView):
class SpecialPublicationsView(PublicationListView): class SpecialPublicationsView(PublicationListView):
""" Display the list of special publications """ """Display the list of special publications"""
def additional_context(self): def additional_context(self):
siteConf = SiteConfiguration.get_solo() siteConf = SiteConfiguration.get_solo()
@ -92,6 +92,6 @@ class SpecialPublicationsView(PublicationListView):
def latestPublication(req): def latestPublication(req):
""" Redirects to the latest standard publication """ """Redirects to the latest standard publication"""
latestPubli = Publication.latest() latestPubli = Publication.latest()
return redirect(latestPubli.url) return redirect(latestPubli.url)

View file

@ -1,18 +1,17 @@
#!/usr/bin/env python #!/usr/bin/env python
import os import os
import sys import sys
from importlib.util import find_spec
if __name__ == "__main__": if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bocal.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError: except ImportError:
# The above import may fail for some other reason. Ensure that the # The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other # issue is really that Django is missing to avoid masking other
# exceptions on Python 2. # exceptions on Python 2.
try: if find_spec("django") is None:
import django
except ImportError:
raise ImportError( raise ImportError(
"Couldn't import Django. Are you sure it's installed and " "Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you " "available on your PYTHONPATH environment variable? Did you "

5
pyproject.toml Normal file
View file

@ -0,0 +1,5 @@
[tool.isort]
profile = "black"
[tool.ruff.lint]
ignore = ["F403", "F405"]