diff --git a/med/admin.py b/med/admin.py index acb795d..78a27b2 100644 --- a/med/admin.py +++ b/med/admin.py @@ -7,7 +7,7 @@ from django.contrib.auth.admin import Group, GroupAdmin from django.contrib.sites.admin import Site, SiteAdmin from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import never_cache -from media.models import Emprunt +from media.models import Borrow class DatabaseAdmin(AdminSite): @@ -22,8 +22,8 @@ class DatabaseAdmin(AdminSite): # User is always authenticated # Get currently borrowed items - user_borrowed = Emprunt.objects.filter(user=request.user, - date_rendu=None) + user_borrowed = Borrow.objects.filter(user=request.user, + given_back=None) response.context_data["borrowed_items"] = user_borrowed return response diff --git a/med/settings.py b/med/settings.py index be9456a..1c90644 100644 --- a/med/settings.py +++ b/med/settings.py @@ -167,9 +167,26 @@ PAGINATION_NUMBER = 25 AUTH_USER_MODEL = 'users.User' -MAX_EMPRUNT = 5 # Max emprunts +NOTE_KFET_URL = 'https://note.crans.org' +NOTE_KFET_CLIENT_ID = 'CHANGE_ME' +NOTE_KFET_CLIENT_SECRET = 'CHANGE_ME' +NOTE_KFET_SCOPES = '1_1 2_1 48_1' try: from .settings_local import * except ImportError: pass + +AUTHLIB_OAUTH_CLIENTS = { + 'notekfet': { + 'client_id': f'{NOTE_KFET_CLIENT_ID}', + 'client_secret': f'{NOTE_KFET_CLIENT_SECRET}', + 'access_token_url': f'{NOTE_KFET_URL}/o/token/', + 'refresh_token_url': f'{NOTE_KFET_URL}/o/token/', + 'authorize_url': f'{NOTE_KFET_URL}/o/authorize/', + 'userinfo_endpoint': f'{NOTE_KFET_URL}/api/me/', + 'client_kwargs': { + 'scope': NOTE_KFET_SCOPES, + } + } +} diff --git a/med/settings_local.example.py b/med/settings_local.example.py index 51fb051..c28549b 100644 --- a/med/settings_local.example.py +++ b/med/settings_local.example.py @@ -40,3 +40,8 @@ DATABASES = { 'PORT': '', } } + +NOTE_KFET_URL = 'https://note.crans.org' +NOTE_KFET_CLIENT_ID = 'CHANGE_ME' +NOTE_KFET_CLIENT_SECRET = 'CHANGE_ME' +NOTE_KFET_SCOPES = '1_1 2_1 48_1' diff --git a/med/urls.py b/med/urls.py index e286a50..e8fe86d 100644 --- a/med/urls.py +++ b/med/urls.py @@ -21,7 +21,7 @@ router.register(r'media/vinyl', media.views.VinylViewSet) router.register(r'media/novel', media.views.NovelViewSet) router.register(r'media/review', media.views.ReviewViewSet) router.register(r'media/future', media.views.FutureMediumViewSet) -router.register(r'borrowed_items', media.views.EmpruntViewSet) +router.register(r'borrowed_items', media.views.BorrowViewSet) router.register(r'games', media.views.GameViewSet) router.register(r'users', users.views.UserViewSet) router.register(r'groups', users.views.GroupViewSet) diff --git a/media/admin.py b/media/admin.py index 8a11026..19e93bb 100644 --- a/media/admin.py +++ b/media/admin.py @@ -2,7 +2,6 @@ # Copyright (C) 2017-2019 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.urls import reverse from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ from polymorphic.admin import PolymorphicChildModelAdmin, \ @@ -11,7 +10,7 @@ from med.admin import admin_site from reversion.admin import VersionAdmin from .forms import MediaAdminForm -from .models import Author, Borrowable, CD, Comic, Emprunt, FutureMedium, \ +from .models import Author, Borrow, Borrowable, CD, Comic, FutureMedium, \ Game, Manga, Novel, Review, Vinyl @@ -120,30 +119,15 @@ class ReviewAdmin(VersionAdmin, PolymorphicChildModelAdmin): show_in_index = True -class EmpruntAdmin(VersionAdmin): - list_display = ('media', 'user', 'date_emprunt', 'date_rendu', - 'permanencier_emprunt', 'permanencier_rendu_custom') - search_fields = ('media__title', 'media__side_identifier', - 'user__username', 'date_emprunt', 'date_rendu') - date_hierarchy = 'date_emprunt' - autocomplete_fields = ('media', 'user', 'permanencier_emprunt', - 'permanencier_rendu') - - def permanencier_rendu_custom(self, obj): - """ - Show a button if item has not been returned yet - """ - if obj.permanencier_rendu: - return obj.permanencier_rendu - else: - return format_html( - '{}', - reverse('media:retour-emprunt', args=[obj.pk]), - _('Turn back') - ) - - permanencier_rendu_custom.short_description = _('given back to') - permanencier_rendu_custom.allow_tags = True +class BorrowAdmin(VersionAdmin): + list_display = ('borrowable', 'user', 'borrow_date', 'borrowed_with', + 'given_back_to') + search_fields = ('borrowable__isbn', 'borrowable__title', + 'borrowable__medium__side_identifier', + 'user__username', 'borrow_date', 'given_back') + date_hierarchy = 'borrow_date' + autocomplete_fields = ('borrowable', 'user', 'borrowed_with', + 'given_back_to') def add_view(self, request, form_url='', extra_context=None): """ @@ -151,7 +135,7 @@ class EmpruntAdmin(VersionAdmin): """ # Make GET data mutable data = request.GET.copy() - data['permanencier_emprunt'] = request.user + data['borrowed_with'] = request.user request.GET = data return super().add_view(request, form_url, extra_context) @@ -173,5 +157,5 @@ admin_site.register(CD, CDAdmin) admin_site.register(Vinyl, VinylAdmin) admin_site.register(Review, ReviewAdmin) admin_site.register(FutureMedium, FutureMediumAdmin) -admin_site.register(Emprunt, EmpruntAdmin) +admin_site.register(Borrow, BorrowAdmin) admin_site.register(Game, GameAdmin) diff --git a/media/locale/fr/LC_MESSAGES/django.po b/media/locale/fr/LC_MESSAGES/django.po index 7e2ba46..6c0d5bc 100644 --- a/media/locale/fr/LC_MESSAGES/django.po +++ b/media/locale/fr/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-10-26 15:14+0200\n" +"POT-Creation-Date: 2021-11-14 14:25+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -13,8 +13,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: admin.py:46 admin.py:102 admin.py:114 models.py:30 models.py:77 -#: models.py:149 models.py:221 models.py:290 models.py:348 models.py:394 +#: admin.py:46 admin.py:102 admin.py:114 models.py:30 models.py:85 msgid "authors" msgstr "auteurs" @@ -22,55 +21,47 @@ msgstr "auteurs" msgid "external url" msgstr "URL externe" -#: admin.py:142 -msgid "Turn back" -msgstr "Rendre" - -#: admin.py:145 models.py:574 -msgid "given back to" -msgstr "rendu à" - #: fields.py:17 msgid "ISBN-10 or ISBN-13" msgstr "ISBN-10 ou ISBN-13" -#: forms.py:301 +#: forms.py:302 msgid "This ISBN is not found." msgstr "L'ISBN n'a pas été trouvé." -#: management/commands/migrate_to_new_format.py:52 models.py:408 models.py:415 +#: management/commands/migrate_to_new_format.py:57 models.py:156 msgid "CDs" msgstr "CDs" -#: management/commands/migrate_to_new_format.py:52 models.py:407 models.py:414 +#: management/commands/migrate_to_new_format.py:57 models.py:155 msgid "CD" msgstr "CD" -#: management/commands/migrate_to_new_format.py:68 models.py:362 models.py:377 +#: management/commands/migrate_to_new_format.py:73 models.py:149 msgid "vinyls" msgstr "vinyles" -#: management/commands/migrate_to_new_format.py:68 models.py:361 models.py:376 +#: management/commands/migrate_to_new_format.py:73 models.py:148 msgid "vinyl" msgstr "vinyle" -#: management/commands/migrate_to_new_format.py:86 models.py:466 models.py:506 +#: management/commands/migrate_to_new_format.py:91 models.py:196 msgid "reviews" msgstr "revues" -#: management/commands/migrate_to_new_format.py:86 models.py:465 models.py:505 +#: management/commands/migrate_to_new_format.py:91 models.py:195 msgid "review" msgstr "revue" -#: management/commands/migrate_to_new_format.py:106 models.py:629 models.py:670 +#: management/commands/migrate_to_new_format.py:111 models.py:315 msgid "games" msgstr "jeux" -#: management/commands/migrate_to_new_format.py:106 models.py:628 models.py:669 +#: management/commands/migrate_to_new_format.py:111 models.py:314 msgid "game" msgstr "jeu" -#: models.py:17 models.py:598 +#: models.py:17 msgid "name" msgstr "nom" @@ -82,63 +73,59 @@ msgstr "note" msgid "author" msgstr "auteur" -#: models.py:37 models.py:127 models.py:199 models.py:268 models.py:329 -#: models.py:383 models.py:421 -msgid "title" -msgstr "titre" - -#: models.py:41 models.py:165 models.py:237 models.py:306 models.py:352 -#: models.py:398 models.py:456 models.py:530 -msgid "present" -msgstr "présent" - -#: models.py:42 models.py:166 models.py:238 models.py:307 models.py:353 -#: models.py:399 models.py:457 models.py:531 -msgid "Tell that the medium is present in the Mediatek." -msgstr "Indique que le medium est présent à la Mediatek." - -#: models.py:60 -msgid "borrowable" -msgstr "empruntable" - -#: models.py:61 -msgid "borrowables" -msgstr "empruntables" - -#: models.py:66 models.py:138 models.py:210 models.py:279 -msgid "external URL" -msgstr "URL externe" - -#: models.py:71 models.py:143 models.py:215 models.py:284 models.py:334 -#: models.py:388 -msgid "side identifier" -msgstr "côte" - -#: models.py:81 -msgid "medium" -msgstr "medium" - -#: models.py:82 -msgid "media" -msgstr "media" - -#: models.py:87 models.py:119 models.py:191 models.py:260 models.py:512 +#: models.py:36 models.py:202 msgid "ISBN" msgstr "ISBN" -#: models.py:88 models.py:120 models.py:192 models.py:261 models.py:513 +#: models.py:37 models.py:203 msgid "You may be able to scan it from a bar code." msgstr "Peut souvent être scanné à partir du code barre." -#: models.py:95 models.py:132 models.py:204 models.py:273 +#: models.py:45 +msgid "title" +msgstr "titre" + +#: models.py:49 models.py:220 +msgid "present" +msgstr "présent" + +#: models.py:50 models.py:221 +msgid "Tell that the medium is present in the Mediatek." +msgstr "Indique que le medium est présent à la Mediatek." + +#: models.py:68 +msgid "borrowable" +msgstr "empruntable" + +#: models.py:69 +msgid "borrowables" +msgstr "empruntables" + +#: models.py:74 +msgid "external URL" +msgstr "URL externe" + +#: models.py:79 +msgid "side identifier" +msgstr "côte" + +#: models.py:89 +msgid "medium" +msgstr "medium" + +#: models.py:90 +msgid "media" +msgstr "media" + +#: models.py:95 msgid "subtitle" msgstr "sous-titre" -#: models.py:101 models.py:153 models.py:225 models.py:294 +#: models.py:101 msgid "number of pages" msgstr "nombre de pages" -#: models.py:107 models.py:159 models.py:231 models.py:300 +#: models.py:107 msgid "publish date" msgstr "date de publication" @@ -150,135 +137,143 @@ msgstr "livre" msgid "books" msgstr "livres" -#: models.py:177 models.py:184 +#: models.py:119 msgid "comic" msgstr "BD" -#: models.py:178 models.py:185 +#: models.py:120 msgid "comics" msgstr "BDs" -#: models.py:246 models.py:253 +#: models.py:126 msgid "manga" msgstr "manga" -#: models.py:247 models.py:254 +#: models.py:127 msgid "mangas" msgstr "mangas" -#: models.py:315 models.py:322 +#: models.py:133 msgid "novel" msgstr "roman" -#: models.py:316 models.py:323 +#: models.py:134 msgid "novels" msgstr "romans" -#: models.py:339 models.py:368 +#: models.py:140 msgid "rounds per minute" msgstr "tours par minute" -#: models.py:341 models.py:370 +#: models.py:142 msgid "33 RPM" msgstr "33 TPM" -#: models.py:342 models.py:371 +#: models.py:143 msgid "45 RPM" msgstr "45 TPM" -#: models.py:426 models.py:472 +#: models.py:162 msgid "number" msgstr "nombre" -#: models.py:430 models.py:476 +#: models.py:166 msgid "year" msgstr "année" -#: models.py:437 models.py:483 +#: models.py:173 msgid "month" msgstr "mois" -#: models.py:444 models.py:490 +#: models.py:180 msgid "day" msgstr "jour" -#: models.py:451 models.py:497 +#: models.py:187 msgid "double" msgstr "double" -#: models.py:520 +#: models.py:210 msgid "type" msgstr "type" -#: models.py:522 +#: models.py:212 msgid "Comic" msgstr "BD" -#: models.py:523 +#: models.py:213 msgid "Manga" msgstr "Manga" -#: models.py:524 +#: models.py:214 msgid "Roman" msgstr "Roman" -#: models.py:536 +#: models.py:226 msgid "future medium" msgstr "medium à importer" -#: models.py:537 +#: models.py:227 msgid "future media" msgstr "medias à importer" -#: models.py:551 +#: models.py:237 +msgid "object" +msgstr "objet" + +#: models.py:242 msgid "borrower" msgstr "emprunteur" -#: models.py:554 +#: models.py:245 msgid "borrowed on" msgstr "emprunté le" -#: models.py:559 +#: models.py:250 msgid "given back on" msgstr "rendu le" -#: models.py:565 +#: models.py:256 msgid "borrowed with" msgstr "emprunté avec" -#: models.py:566 +#: models.py:257 msgid "The keyholder that registered this borrowed item." msgstr "Le permanencier qui enregistre cet emprunt." -#: models.py:575 +#: models.py:265 +msgid "given back to" +msgstr "rendu à" + +#: models.py:266 msgid "The keyholder to whom this item was given back." msgstr "Le permanencier à qui l'emprunt a été rendu." -#: models.py:582 +#: models.py:273 msgid "borrowed item" msgstr "emprunt" -#: models.py:583 +#: models.py:274 msgid "borrowed items" msgstr "emprunts" -#: models.py:603 models.py:644 +#: models.py:289 msgid "owner" msgstr "propriétaire" -#: models.py:608 models.py:649 +#: models.py:294 msgid "duration" msgstr "durée" -#: models.py:612 models.py:653 +#: models.py:298 msgid "minimum number of players" msgstr "nombre minimum de joueurs" -#: models.py:616 models.py:657 +#: models.py:302 msgid "maximum number of players" msgstr "nombre maximum de joueurs" -#: models.py:621 models.py:662 +#: models.py:307 msgid "comment" msgstr "commentaire" @@ -306,6 +301,6 @@ msgstr "ISBN invalide : mauvaise longueur" msgid "Invalid ISBN: Only upper case allowed" msgstr "ISBN invalide : seulement les majuscules sont autorisées" -#: views.py:47 +#: views.py:25 msgid "Welcome to the Mediatek database" msgstr "Bienvenue sur la base de données de la Mediatek" diff --git a/media/management/commands/migrate_to_new_format.py b/media/management/commands/migrate_to_new_format.py index c718b3f..cb3d34e 100644 --- a/media/management/commands/migrate_to_new_format.py +++ b/media/management/commands/migrate_to_new_format.py @@ -20,7 +20,7 @@ class Command(BaseCommand): "Old data structure has been deleted. This script won't work " "anymore (and is now useless)")) - from media.models import OldCD, OldComic, OldGame, OldManga, OldNovel, \ + from media.models import OldCD, OldComic, OldGame, OldManga, OldNovel,\ OldReview, OldVinyl # Migrate books diff --git a/media/migrations/0045_auto_20211114_1423.py b/media/migrations/0045_auto_20211114_1423.py new file mode 100644 index 0000000..a719a07 --- /dev/null +++ b/media/migrations/0045_auto_20211114_1423.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.24 on 2021-11-14 13:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('media', '0044_auto_20211102_1254'), + ] + + operations = [ + migrations.CreateModel( + name='Borrow', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('borrow_date', models.DateTimeField(verbose_name='borrowed on')), + ('given_back', models.DateTimeField(blank=True, null=True, verbose_name='given back on')), + ('borrowable', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='media.Borrowable', verbose_name='object')), + ('borrowed_with', models.ForeignKey(help_text='The keyholder that registered this borrowed item.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='borrowed with')), + ('given_back_to', models.ForeignKey(blank=True, help_text='The keyholder to whom this item was given back.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='given back to')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='borrower')), + ], + options={ + 'verbose_name': 'borrowed item', + 'verbose_name_plural': 'borrowed items', + 'ordering': ['-borrow_date'], + }, + ), + migrations.DeleteModel( + name='Emprunt', + ), + ] diff --git a/media/models.py b/media/models.py index 2d5ccfb..eda6597 100644 --- a/media/models.py +++ b/media/models.py @@ -1,7 +1,7 @@ # -*- mode: python; coding: utf-8 -*- -# Copyright (C) 2017-2019 by BDE ENS Paris-Saclay +# Copyright (C) 2017-2021 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +from django.conf import settings from django.core.validators import MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ @@ -230,35 +230,36 @@ class FutureMedium(models.Model): return "Future medium (ISBN: {isbn})".format(isbn=self.isbn, ) -class Emprunt(models.Model): - media = models.ForeignKey( +class Borrow(models.Model): + borrowable = models.ForeignKey( 'media.Borrowable', on_delete=models.PROTECT, + verbose_name=_('object'), ) user = models.ForeignKey( - 'users.User', + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, verbose_name=_("borrower"), ) - date_emprunt = models.DateTimeField( + borrow_date = models.DateTimeField( verbose_name=_('borrowed on'), ) - date_rendu = models.DateTimeField( + given_back = models.DateTimeField( blank=True, null=True, verbose_name=_('given back on'), ) - permanencier_emprunt = models.ForeignKey( - 'users.User', + borrowed_with = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, - related_name='user_permanencier_emprunt', + related_name='+', verbose_name=_('borrowed with'), help_text=_('The keyholder that registered this borrowed item.') ) - permanencier_rendu = models.ForeignKey( - 'users.User', + given_back_to = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, - related_name='user_permanencier_rendu', + related_name='+', blank=True, null=True, verbose_name=_('given back to'), @@ -266,12 +267,12 @@ class Emprunt(models.Model): ) def __str__(self): - return str(self.media) + str(self.user) + return str(self.borrowable) + str(self.user) class Meta: verbose_name = _("borrowed item") verbose_name_plural = _("borrowed items") - ordering = ['-date_emprunt'] + ordering = ['-borrow_date'] class Game(Borrowable): diff --git a/media/serializers.py b/media/serializers.py index 97c0ff5..aea4f08 100644 --- a/media/serializers.py +++ b/media/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Author, CD, Comic, FutureMedium, Manga, Emprunt, Game, \ +from .models import Author, Borrow, CD, Comic, FutureMedium, Manga, Game, \ Novel, Review, Vinyl @@ -52,15 +52,13 @@ class FutureMediumSerializer(serializers.ModelSerializer): fields = '__all__' -class EmpruntSerializer(serializers.HyperlinkedModelSerializer): +class BorrowSerializer(serializers.HyperlinkedModelSerializer): class Meta: - model = Emprunt - fields = ['url', 'media', 'user', 'date_emprunt', 'date_rendu', - 'permanencier_emprunt', 'permanencier_rendu'] + model = Borrow + fields = '__all__' class GameSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Game - fields = ['url', 'name', 'proprietaire', 'duree', 'nombre_joueurs_min', - 'nombre_joueurs_max', 'comment'] + fields = '__all__' diff --git a/media/tests/test_templates.py b/media/tests/test_templates.py index d6bf8b9..15b6df9 100644 --- a/media/tests/test_templates.py +++ b/media/tests/test_templates.py @@ -55,10 +55,10 @@ class TemplateTests(TestCase): ), data=data) self.assertEqual(response.status_code, 302) - def test_comic_emprunt_changelist(self): - response = self.client.get(reverse('admin:media_emprunt_changelist')) + def test_comic_borrow_changelist(self): + response = self.client.get(reverse('admin:media_borrow_changelist')) self.assertEqual(response.status_code, 200) - def test_comic_emprunt_add(self): - response = self.client.get(reverse('admin:media_emprunt_add')) + def test_comic_borrow_add(self): + response = self.client.get(reverse('admin:media_borrow_add')) self.assertEqual(response.status_code, 200) diff --git a/media/urls.py b/media/urls.py index 261d559..973bf45 100644 --- a/media/urls.py +++ b/media/urls.py @@ -2,15 +2,12 @@ # Copyright (C) 2017-2019 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.conf.urls import url from django.urls import path from . import views app_name = 'media' urlpatterns = [ - url(r'^retour_emprunt/(?P[0-9]+)$', views.retour_emprunt, - name='retour-emprunt'), path('find/', views.FindMediumView.as_view(), name="find"), path('mark-as-present/comic//', views.MarkComicAsPresent.as_view(), diff --git a/media/views.py b/media/views.py index bd0e2f4..4934079 100644 --- a/media/views.py +++ b/media/views.py @@ -2,42 +2,20 @@ # Copyright (C) 2017-2019 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.contrib import messages -from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponse from django_filters.rest_framework import DjangoFilterBackend -from django.db import transaction from django.shortcuts import redirect -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView, DetailView from rest_framework import viewsets from rest_framework.filters import SearchFilter -from reversion import revisions as reversion -from .models import Author, CD, Comic, Emprunt, FutureMedium, Game, Manga,\ +from .models import Author, Borrow, CD, Comic, FutureMedium, Game, Manga,\ Novel, Review, Vinyl -from .serializers import AuthorSerializer, ComicSerializer, CDSerializer,\ - EmpruntSerializer, FutureMediumSerializer, GameSerializer, \ - MangaSerializer, NovelSerializer, ReviewSerializer, VinylSerializer - - -@login_required -@permission_required('media.change_emprunt') -def retour_emprunt(request, empruntid): - try: - emprunt_instance = Emprunt.objects.get(pk=empruntid) - except Emprunt.DoesNotExist: - messages.error(request, u"Entrée inexistante") - return redirect("admin:media_emprunt_changelist") - with transaction.atomic(), reversion.create_revision(): - emprunt_instance.permanencier_rendu = request.user - emprunt_instance.date_rendu = timezone.now() - emprunt_instance.save() - reversion.set_user(request.user) - messages.success(request, "Retour enregistré") - return redirect("admin:media_emprunt_changelist") +from .serializers import AuthorSerializer, BorrowSerializer, ComicSerializer, \ + CDSerializer, FutureMediumSerializer, GameSerializer, MangaSerializer, \ + NovelSerializer, ReviewSerializer, VinylSerializer class IndexView(TemplateView): @@ -181,12 +159,12 @@ class FutureMediumViewSet(viewsets.ModelViewSet): search_fields = ["=isbn"] -class EmpruntViewSet(viewsets.ModelViewSet): +class BorrowViewSet(viewsets.ModelViewSet): """ API endpoint that allows borrowed items to be viewed or edited. """ - queryset = Emprunt.objects.all() - serializer_class = EmpruntSerializer + queryset = Borrow.objects.all() + serializer_class = BorrowSerializer class GameViewSet(viewsets.ModelViewSet): diff --git a/requirements.txt b/requirements.txt index 7f2237e..19a6e17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +authlib~=0.15 docutils~=0.16 # for Django-admin docs Django~=2.2 django-filter~=2.4 diff --git a/theme/templates/admin/base_site.html b/theme/templates/admin/base_site.html index 4adb2d8..5a4d054 100644 --- a/theme/templates/admin/base_site.html +++ b/theme/templates/admin/base_site.html @@ -54,7 +54,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% if user.is_authenticated %} {% trans 'Log out' %} {% else %} - {% trans 'Log in' %} + {% trans 'Log in' %} {% endif %} {% endblock %} diff --git a/theme/templates/admin/index.html b/theme/templates/admin/index.html index 08c3498..4b285ff 100644 --- a/theme/templates/admin/index.html +++ b/theme/templates/admin/index.html @@ -56,9 +56,6 @@ SPDX-License-Identifier: GPL-3.0-or-later

{% trans 'My profile' %} - - {% trans 'Edit' %} -

  • {% trans 'username' %} : {{ user.username }}
  • @@ -67,10 +64,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
  • {% trans 'date joined' %} : {{ user.date_joined }}
  • {% trans 'last login' %} : {{ user.last_login }}
  • {% trans 'address' %} : {{ user.address }}
  • -
  • {% trans 'phone number' %} : {{ user.telephone }}
  • +
  • {% trans 'phone number' %} : {{ user.phone_number }}
  • {% trans 'groups' %} : {% for g in user.groups.all %}{{ g.name }} {% endfor %}
  • -
  • {% trans 'maximum borrowed' %} : {{ user.maxemprunt }}
  • {% trans 'membership for current year' %} : {% if user.is_member %} @@ -84,8 +80,8 @@ SPDX-License-Identifier: GPL-3.0-or-later

    {% trans 'Current borrowed items' %}

    {% if borrowed_items %}
      - {% for emprunt in borrowed_items %} -
    • {{ emprunt.media }} ({% trans 'since' %} {{ emprunt.date_emprunt }})
    • + {% for borrow in borrowed_items %} +
    • {{ borrow.object }} ({% trans 'since' %} {{ borrow.borrow_date }})
    • {% endfor %}
    {% else %} diff --git a/users/admin.py b/users/admin.py index 6849ef6..bfd0fa9 100644 --- a/users/admin.py +++ b/users/admin.py @@ -3,16 +3,13 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.contrib import admin -from django.contrib import messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from django.contrib.auth.forms import PasswordResetForm -from django.urls import reverse -from django.utils.html import format_html +from django.utils import timezone +from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from reversion.admin import VersionAdmin from med.admin import admin_site -from .forms import UserCreationAdminForm from .models import User @@ -26,7 +23,12 @@ class IsMemberFilter(admin.SimpleListFilter): ) def queryset(self, request, queryset): - # FIXME Replace with imported Note Kfet memberships + if self.parameter_name in request.GET: + queryset = queryset.filter( + membership__date_start__lte=timezone.now(), + membership__date_end__gte=timezone.now(), + ).distinct() + return queryset @@ -35,61 +37,32 @@ class UserAdmin(VersionAdmin, BaseUserAdmin): fieldsets = ( (None, {'fields': ('username', 'password')}), (_('Personal info'), {'fields': ('first_name', 'last_name', 'email', - 'telephone', 'address', 'comment')}), + 'phone_number', 'address', + 'comment')}), (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', - 'groups', 'user_permissions', - 'maxemprunt')}), + 'groups', 'user_permissions')}), (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) list_display = ('username', 'email', 'first_name', 'last_name', - 'maxemprunt', 'is_member', 'is_staff') + 'is_member', 'is_staff') list_filter = (IsMemberFilter, 'is_staff', 'is_superuser', 'is_active', 'groups') - # Customize required initial fields - add_form_template = 'admin/change_form.html' - add_form = UserCreationAdminForm - add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ("username", "email", "first_name", "last_name", - "address", "telephone"), - }), - ) - - def save_model(self, request, obj, form, change): - """ - On creation, send a password init mail - """ - super().save_model(request, obj, form, change) - - if not change: - # Virtually fill the password reset form - password_reset = PasswordResetForm(data={'email': obj.email}) - if password_reset.is_valid(): - password_reset.save(request=request, - use_https=request.is_secure()) - messages.success(request, _("An email to set the password" - " was sent.")) - else: - messages.error(request, _("The email is invalid.")) + def has_add_permission(self, request): + # Only add users through Note Kfet login + return False def is_member(self, obj): """ Get current membership year and check if user is there """ - # FIXME Use NK20 - is_member = True - if is_member: - return format_html( + if obj.is_member: + return mark_safe( 'True' ) else: - return format_html( - 'False ' - '{}', - reverse('users:adherer', args=[obj.pk]), - _('Adhere') + return mark_safe( + 'False' ) is_member.short_description = _('is member') diff --git a/users/forms.py b/users/forms.py deleted file mode 100644 index 41f0a9b..0000000 --- a/users/forms.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- mode: python; coding: utf-8 -*- -# Copyright (C) 2017-2019 by BDE ENS Paris-Saclay -# SPDX-License-Identifier: GPL-3.0-or-later - -from django import forms -from django.contrib.auth.forms import UsernameField -from django.core.validators import MinLengthValidator -from django.forms import ModelForm - -from .models import User - - -class PassForm(forms.Form): - passwd1 = forms.CharField( - label=u'Nouveau mot de passe', - max_length=255, - validators=[MinLengthValidator(8)], - widget=forms.PasswordInput, - ) - passwd2 = forms.CharField( - label=u'Saisir à nouveau le mot de passe', - max_length=255, - validators=[MinLengthValidator(8)], - widget=forms.PasswordInput - ) - - -class BaseInfoForm(ModelForm): - class Meta: - model = User - fields = [ - 'username', - 'email', - 'first_name', - 'last_name', - 'address', - 'telephone', - ] - - -class UserCreationAdminForm(ModelForm): - """ - A form that creates a user, with no privileges, - from the given information. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['email'].required = True - self.fields['first_name'].required = True - self.fields['last_name'].required = True - - class Meta: - model = User - fields = ("username", "email", "first_name", "last_name", "address", - "telephone") - field_classes = {'username': UsernameField} diff --git a/users/migrations/0043_accesstoken.py b/users/migrations/0043_accesstoken.py new file mode 100644 index 0000000..60b8436 --- /dev/null +++ b/users/migrations/0043_accesstoken.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.24 on 2021-11-02 15:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0042_delete_adhesion'), + ] + + operations = [ + migrations.CreateModel( + name='AccessToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('access_token', models.CharField(max_length=32, verbose_name='access token')), + ('expires_in', models.PositiveIntegerField(verbose_name='expires in')), + ('scopes', models.CharField(max_length=255, verbose_name='scopes')), + ('refresh_token', models.CharField(max_length=32, verbose_name='refresh token')), + ('expires_at', models.DateTimeField(verbose_name='expires at')), + ('owner', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='owner')), + ], + options={ + 'verbose_name': 'access token', + 'verbose_name_plural': 'access tokens', + }, + ), + ] diff --git a/users/migrations/0044_membership.py b/users/migrations/0044_membership.py new file mode 100644 index 0000000..188a697 --- /dev/null +++ b/users/migrations/0044_membership.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.24 on 2021-11-04 13:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0043_accesstoken'), + ] + + operations = [ + migrations.CreateModel( + name='Membership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_start', models.DateField(auto_now_add=True, verbose_name='start date')), + ('date_end', models.DateField(auto_now_add=True, verbose_name='start date')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'membership', + 'verbose_name_plural': 'memberships', + }, + ), + ] diff --git a/users/migrations/0045_auto_20211114_1423.py b/users/migrations/0045_auto_20211114_1423.py new file mode 100644 index 0000000..6bca1cd --- /dev/null +++ b/users/migrations/0045_auto_20211114_1423.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.24 on 2021-11-14 13:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0044_membership'), + ] + + operations = [ + migrations.RenameField( + model_name='user', + old_name='telephone', + new_name='phone_number', + ), + migrations.RemoveField( + model_name='user', + name='maxemprunt', + ), + ] diff --git a/users/models.py b/users/models.py index 3b5cf51..36f4d60 100644 --- a/users/models.py +++ b/users/models.py @@ -1,16 +1,21 @@ # -*- mode: python; coding: utf-8 -*- -# Copyright (C) 2017-2019 by BDE ENS Paris-Saclay +# Copyright (C) 2017-2021 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from datetime import datetime + +import requests +from authlib.integrations.django_client import OAuth +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from django.db.models import Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from med.settings import MAX_EMPRUNT class User(AbstractUser): - telephone = models.CharField( + phone_number = models.CharField( verbose_name=_('phone number'), max_length=15, blank=True, @@ -20,12 +25,6 @@ class User(AbstractUser): max_length=255, blank=True, ) - maxemprunt = models.IntegerField( - verbose_name=_('maximum borrowed'), - help_text=_('Maximal amount of simultaneous borrowed item ' - 'authorized.'), - default=MAX_EMPRUNT, - ) comment = models.CharField( verbose_name=_('comment'), help_text=_('Promotion...'), @@ -33,7 +32,7 @@ class User(AbstractUser): blank=True, ) date_joined = models.DateTimeField( - _('date joined'), + verbose_name=_('date joined'), default=timezone.now, null=True, ) @@ -42,5 +41,173 @@ class User(AbstractUser): @property def is_member(self): - # FIXME Use NK20 - return True + """ + Return True if user is member of the club. + """ + return Membership.objects.filter( + user=self, + date_start__lte=timezone.now(), + date_end__gte=timezone.now()).exists() + + def update_data(self, data: dict): + """ + Update user data from given dictionary. + Useful when we want to update user data from Note Kfet. + + Parameters + ---------- + data : dict + Dictionary with user data to update. + """ + self.email = data['email'] + self.first_name = data['first_name'] + self.last_name = data['last_name'] + self.phone_number = data['profile']['phone_number'] + self.address = data['profile']['address'] + self.comment = data['profile']['section'] + + for membership_dict in data['memberships']: + if membership_dict['club'] != 22: # Med + continue + # Add membership if not exists + Membership.objects.get_or_create( + user=self, + date_start=membership_dict['date_start'], + date_end=membership_dict['date_end'], + ) + + # Only members or old members are allow to connect to the website + self.is_active = Membership.objects.filter(user=self).exists() + + +class Membership(models.Model): + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + verbose_name=_('user'), + ) + + date_start = models.DateField( + auto_now_add=True, + verbose_name=_('start date'), + ) + + date_end = models.DateField( + auto_now_add=True, + verbose_name=_('start date'), + ) + + def __str__(self): + return f'{self.user}: {self.date_start} to {self.date_end}' + + class Meta: + verbose_name = _('membership') + verbose_name_plural = _('memberships') + + +class AccessToken(models.Model): + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + default=None, + verbose_name=_('owner'), + ) + + access_token = models.CharField( + max_length=32, + verbose_name=_('access token'), + ) + + expires_in = models.PositiveIntegerField( + verbose_name=_('expires in'), + ) + + scopes = models.CharField( + max_length=255, + verbose_name=_('scopes'), + ) + + refresh_token = models.CharField( + max_length=32, + verbose_name=_('refresh token'), + ) + + expires_at = models.DateTimeField( + verbose_name=_('expires at'), + ) + + def refresh(self): + """ + Refresh the access token. + """ + oauth = OAuth() + oauth.register('notekfet') + # Get the OAuth client + oauth_client = oauth.notekfet._get_oauth_client() + # Actually refresh the token + token = oauth_client.refresh_token(oauth.notekfet.access_token_url, + refresh_token=self.refresh_token) + self.access_token = token['access_token'] + self.expires_in = token['expires_in'] + self.scopes = token['scope'] + self.refresh_token = token['refresh_token'] + self.expires_at = timezone.utc.fromutc( + datetime.fromtimestamp(token['expires_at']) + ) + + self.save() + + def refresh_if_expired(self): + """ + Refresh the current token if it is invalid. + """ + if self.expires_at < timezone.now(): + self.refresh() + + def auth_header(self): + """ + Return HTTP header that contains the bearer access token. + Refresh the token if needed. + """ + self.refresh_if_expired() + return {'Authorization': f'Bearer {self.access_token}'} + + def fetch_user(self, create_if_not_exist: bool = False): + """ + Extract information about the Note Kfet API by using the current + access token. + """ + data = requests.get(f'{settings.NOTE_KFET_URL}/api/me/', + headers=self.auth_header()).json() + username = data['username'] + email = data['email'] + qs = User.objects.filter(Q(username=username) | Q(email=email)) + if not qs.exists(): + if create_if_not_exist: + user = User.objects.create(username=username, email=email) + else: + return None + else: + user = qs.get() + + # Update user data from Note Kfet + user.update_data(data) + user.save() + + # Store token owner + self.owner = user + self.save() + + return user + + @classmethod + def get_token(cls, request): + return AccessToken.objects.get(pk=request.session['access_token_id']) + + def __str__(self): + return self.access_token + + class Meta: + verbose_name = _('access token') + verbose_name_plural = _('access tokens') diff --git a/users/serializers.py b/users/serializers.py index e1b5faf..6dfd05a 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -8,7 +8,7 @@ class UserSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = User fields = ['url', 'username', 'first_name', 'last_name', 'email', - 'groups', 'telephone', 'address', 'maxemprunt', 'comment', + 'groups', 'phone_number', 'address', 'comment', 'date_joined'] diff --git a/users/tests/test_templates.py b/users/tests/test_templates.py index f94e9b7..66879e5 100644 --- a/users/tests/test_templates.py +++ b/users/tests/test_templates.py @@ -1,7 +1,6 @@ # -*- mode: python; coding: utf-8 -*- # SPDX-License-Identifier: GPL-3.0-or-later -from django.core import mail from django.test import TestCase from django.urls import reverse from users.models import User @@ -20,30 +19,6 @@ class TemplateTests(TestCase): ) self.client.force_login(self.user) - def test_users_edit_info(self): - response = self.client.get(reverse('users:edit-info')) - self.assertEqual(response.status_code, 200) - def test_users_user_changelist(self): response = self.client.get(reverse('admin:users_user_changelist')) self.assertEqual(response.status_code, 200) - - def test_users_user_creation_form(self): - response = self.client.get(reverse('admin:users_user_add')) - self.assertEqual(response.status_code, 200) - - def test_users_user_add_init_mail(self): - """ - Test that an initialization mail is send when a new user is added - """ - data = { - 'username': "test_user", - 'email': "test@example.com", - 'first_name': "Test", - 'last_name': "User", - } - response = self.client.post(reverse( - 'admin:users_user_add', - ), data=data) - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(response.status_code, 302) diff --git a/users/urls.py b/users/urls.py index 457e218..54fb5f1 100644 --- a/users/urls.py +++ b/users/urls.py @@ -8,5 +8,6 @@ from . import views app_name = 'users' urlpatterns = [ - url(r'^edit_info/$', views.edit_info, name='edit-info'), + url('login/', views.LoginView.as_view(), name='login'), + url('authorize/', views.AuthorizeView.as_view(), name='auth'), ] diff --git a/users/views.py b/users/views.py index 0f5b17a..025961a 100644 --- a/users/views.py +++ b/users/views.py @@ -1,47 +1,47 @@ # -*- mode: python; coding: utf-8 -*- # Copyright (C) 2017-2019 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from datetime import datetime -from django.contrib import messages -from django.contrib.auth.decorators import login_required +from authlib.integrations.django_client import OAuth +from django.contrib.auth import login from django.contrib.auth.models import Group -from django.db import transaction -from django.shortcuts import redirect, render -from django.template.context_processors import csrf -from django.utils.translation import ugettext_lazy as _ +from django.urls import reverse +from django.utils import timezone +from django.views.generic import RedirectView from rest_framework import viewsets -from reversion import revisions as reversion -from users.forms import BaseInfoForm -from users.models import User +from users.models import User, AccessToken from .serializers import GroupSerializer, UserSerializer -def form(ctx, template, request): - c = ctx - c.update(csrf(request)) - return render(request, template, c) +class LoginView(RedirectView): + def get_redirect_url(self, *args, **kwargs): + oauth = OAuth() + oauth.register('notekfet') + redirect_url = self.request.build_absolute_uri(reverse('users:auth')) + return oauth.notekfet.authorize_redirect(self.request, + redirect_url).url -@login_required -def edit_info(request): - """ - Edite son utilisateur - """ - user = BaseInfoForm(request.POST or None, instance=request.user) - if user.is_valid(): - with transaction.atomic(), reversion.create_revision(): - user.save() - reversion.set_user(request.user) - reversion.set_comment("Champs modifié(s) : %s" % ', '.join( - field for field in user.changed_data)) - messages.success(request, "L'user a bien été modifié") - return redirect("index") - return form({ - 'form': user, - 'password_change': True, - 'title': _('Edit user profile'), - }, 'users/user.html', request) +class AuthorizeView(RedirectView): + def get_redirect_url(self, *args, **kwargs): + oauth = OAuth() + oauth.register('notekfet') + token = oauth.notekfet.authorize_access_token(self.request) + token_obj = AccessToken.objects.create( + access_token=token['access_token'], + expires_in=token['expires_in'], + scopes=token['scope'], + refresh_token=token['refresh_token'], + expires_at=timezone.utc.fromutc( + datetime.fromtimestamp(token['expires_at'])), + ) + user = token_obj.fetch_user(True) + self.request.session['access_token_id'] = token_obj.id + self.request.session.save() + login(self.request, user) + return reverse('index') class UserViewSet(viewsets.ModelViewSet):