diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fc79afb6..cada9068 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,8 +18,7 @@ py37-django22: python3-django-extensions python3-django-filters python3-django-polymorphic python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil python3-babel python3-lockfile python3-pip python3-phonenumbers - python3-bs4 python3-setuptools tox - texlive-latex-base texlive-latex-recommended texlive-lang-french lmodern texlive-fonts-recommended + python3-bs4 python3-setuptools tox texlive-xetex script: tox -e py37-django22 # Ubuntu 20.04 @@ -36,8 +35,7 @@ py38-django22: python3-django-extensions python3-django-filters python3-django-polymorphic python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil python3-babel python3-lockfile python3-pip python3-phonenumbers - python3-bs4 python3-setuptools tox - texlive-latex-base texlive-latex-recommended texlive-lang-french lmodern texlive-fonts-recommended + python3-bs4 python3-setuptools tox texlive-xetex script: tox -e py38-django22 linters: diff --git a/Dockerfile b/Dockerfile index 846cc420..0dd1ce8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,7 @@ RUN apt-get update && \ python3-babel python3-lockfile python3-pip python3-phonenumbers ipython3 \ python3-bs4 python3-setuptools \ uwsgi uwsgi-plugin-python3 \ - texlive-latex-base texlive-latex-recommended texlive-lang-french lmodern texlive-fonts-recommended \ - gettext libjs-bootstrap4 fonts-font-awesome && \ + texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome && \ rm -rf /var/lib/apt/lists/* # Instal PyPI requirements diff --git a/README.md b/README.md index a9eab62d..f2ada2a8 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,13 @@ Bien que cela permette de créer une instance sur toutes les distributions, $ sudo apt update $ sudo apt install --no-install-recommends -y \ ipython3 python3-setuptools python3-venv python3-dev \ - texlive-latex-base texlive-lang-french lmodern texlive-fonts-recommended \ - gettext libjs-bootstrap4 fonts-font-awesome git + texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome git ``` 2. **Clonage du dépot** là où vous voulez : ```bash - $ git clone git@gitlab.crans.org:bde/nk20.git && cd nk20 + $ git clone git@gitlab.crans.org:bde/nk20.git --recursive && cd nk20 ``` 3. **Création d'un environment de travail Python décorrélé du système.** @@ -98,8 +97,7 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous. python3-babel python3-lockfile python3-pip python3-phonenumbers ipython3 \ python3-bs4 python3-setuptools \ uwsgi uwsgi-plugin-python3 \ - texlive-latex-base texlive-lang-french lmodern texlive-fonts-recommended \ - gettext libjs-bootstrap4 fonts-font-awesome \ + texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome \ nginx python3-venv git acl ``` @@ -109,7 +107,7 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous. $ sudo mkdir -p /var/www/note_kfet && cd /var/www/note_kfet $ sudo chown www-data:www-data . $ sudo chmod g+rwx . - $ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git + $ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git --recursive ``` 3. **Création d'un environment de travail Python décorrélé du système.** @@ -221,7 +219,7 @@ Il est possible de travailler sur une instance Docker. Pour construire l'image Docker `nk20`, ``` -git clone https://gitlab.crans.org/bde/nk20/ && cd nk20 +git clone https://gitlab.crans.org/bde/nk20/ --recursive && cd nk20 docker build . -t nk20 ``` diff --git a/ansible/roles/1-apt-basic/tasks/main.yml b/ansible/roles/1-apt-basic/tasks/main.yml index 3148327f..1ca1b3d6 100644 --- a/ansible/roles/1-apt-basic/tasks/main.yml +++ b/ansible/roles/1-apt-basic/tasks/main.yml @@ -38,10 +38,7 @@ - python3-venv # LaTeX (PDF generation) - - texlive-fonts-recommended - - texlive-lang-french - - texlive-latex-base - - texlive-latex-recommended + - texlive-xetex # WSGI server - uwsgi diff --git a/apps/member/models.py b/apps/member/models.py index d1218e94..bce525af 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -341,6 +341,9 @@ class Membership(models.Model): return self.date_start.toordinal() <= datetime.datetime.now().toordinal() def renew(self): + """ + If the current membership comes to expiration, create a new membership that starts immediately after this one. + """ if not Membership.objects.filter( user=self.user, club=self.club, @@ -362,6 +365,48 @@ class Membership(models.Model): new_membership.roles.set(self.roles.all()) new_membership.save() + def renew_parent(self): + """ + Ensure that the parent membership is renewed, and renew/create it if needed. + """ + parent_membership = Membership.objects.filter( + user=self.user, + club=self.club.parent_club, + ).order_by("-date_start") + if parent_membership.exists(): + # Renew the previous membership of the parent club + parent_membership = parent_membership.first() + parent_membership._force_renew_parent = True + if hasattr(self, '_soge'): + parent_membership._soge = True + if hasattr(self, '_force_save'): + parent_membership._force_save = True + parent_membership.renew() + else: + # Create a new membership in the parent club + parent_membership = Membership( + user=self.user, + club=self.club.parent_club, + date_start=self.date_start, + ) + parent_membership._force_renew_parent = True + if hasattr(self, '_soge'): + parent_membership._soge = True + if hasattr(self, '_force_save'): + parent_membership._force_save = True + parent_membership.save() + parent_membership.refresh_from_db() + + if self.club.parent_club.name == "BDE": + parent_membership.roles.set( + Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all()) + elif self.club.parent_club.name == "Kfet": + parent_membership.roles.set( + Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all()) + else: + parent_membership.roles.set(Role.objects.filter(name="Membre de club").all()) + parent_membership.save() + def save(self, *args, **kwargs): """ Calculate fee and end date before saving the membership and creating the transaction if needed. @@ -391,43 +436,7 @@ class Membership(models.Model): date_start__gte=self.club.parent_club.membership_start, ).exists(): if hasattr(self, '_force_renew_parent') and self._force_renew_parent: - parent_membership = Membership.objects.filter( - user=self.user, - club=self.club.parent_club, - ).order_by("-date_start") - if parent_membership.exists(): - # Renew the previous membership of the parent club - parent_membership = parent_membership.first() - parent_membership._force_renew_parent = True - if hasattr(self, '_soge'): - parent_membership._soge = True - if hasattr(self, '_force_save'): - parent_membership._force_save = True - parent_membership.renew() - else: - # Create a new membership in the parent club - parent_membership = Membership( - user=self.user, - club=self.club.parent_club, - date_start=self.date_start, - ) - parent_membership._force_renew_parent = True - if hasattr(self, '_soge'): - parent_membership._soge = True - if hasattr(self, '_force_save'): - parent_membership._force_save = True - parent_membership.save() - parent_membership.refresh_from_db() - - if self.club.parent_club.name == "BDE": - parent_membership.roles.set( - Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all()) - elif self.club.parent_club.name == "Kfet": - parent_membership.roles.set( - Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all()) - else: - parent_membership.roles.set(Role.objects.filter(name="Membre de club").all()) - parent_membership.save() + self.renew_parent() else: raise ValidationError(_('User is not a member of the parent club') + ' ' + self.club.parent_club.name) diff --git a/apps/member/views.py b/apps/member/views.py index 2a0394ff..746f5c94 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -584,6 +584,64 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): return context + def perform_verifications(self, form, user, club, fee): + """ + Make some additional verifications to check that the membership can be created. + :return: True if the form is clean, False if there is an error. + """ + error = False + + # Retrieve form data + credit_type = form.cleaned_data["credit_type"] + credit_amount = form.cleaned_data["credit_amount"] + last_name = form.cleaned_data["last_name"] + first_name = form.cleaned_data["first_name"] + bank = form.cleaned_data["bank"] + soge = form.cleaned_data["soge"] and not user.profile.soge and (club.name == "BDE" or club.name == "Kfet") + + if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter( + club__name="Kfet", + user=user, + date_start__lte=date.today(), + date_end__gte=date.today(), + ).exists(): + # Users without a valid Kfet membership can't have a negative balance. + # TODO Send a notification to the user (with a mail?) to tell her/him to credit her/his note + form.add_error('user', + _("This user don't have enough money to join this club, and can't have a negative balance.")) + error = True + + if Membership.objects.filter( + user=form.instance.user, + club=club, + date_start__lte=form.instance.date_start, + date_end__gte=form.instance.date_start, + ).exists(): + form.add_error('user', _('User is already a member of the club')) + error = True + + if club.membership_start and form.instance.date_start < club.membership_start: + form.add_error('user', _("The membership must start after {:%m-%d-%Y}.") + .format(form.instance.club.membership_start)) + error = True + + if club.membership_end and form.instance.date_start > club.membership_end: + form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.") + .format(form.instance.club.membership_end)) + error = True + + if credit_amount: + if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"): + if not last_name: + form.add_error('last_name', _("This field is required.")) + if not first_name: + form.add_error('first_name', _("This field is required.")) + if not bank and credit_type.special_type == "Chèque": + form.add_error('bank', _("This field is required.")) + return self.form_invalid(form) + + return not error + def form_valid(self, form): """ Create membership, check that all is good, make transactions @@ -630,36 +688,9 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): fee += c.membership_fee_paid if user.profile.paid else c.membership_fee_unpaid c = c.parent_club - if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter( - club__name="Kfet", - user=user, - date_start__lte=date.today(), - date_end__gte=date.today(), - ).exists(): - # Users without a valid Kfet membership can't have a negative balance. - # TODO Send a notification to the user (with a mail?) to tell her/him to credit her/his note - form.add_error('user', - _("This user don't have enough money to join this club, and can't have a negative balance.")) - return super().form_invalid(form) - - if Membership.objects.filter( - user=form.instance.user, - club=club, - date_start__lte=form.instance.date_start, - date_end__gte=form.instance.date_start, - ).exists(): - form.add_error('user', _('User is already a member of the club')) - return super().form_invalid(form) - - if club.membership_start and form.instance.date_start < club.membership_start: - form.add_error('user', _("The membership must start after {:%m-%d-%Y}.") - .format(form.instance.club.membership_start)) - return super().form_invalid(form) - - if club.membership_end and form.instance.date_start > club.membership_end: - form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.") - .format(form.instance.club.membership_end)) - return super().form_invalid(form) + # Make some verifications about the form, and if there is an error, then assume that the form is invalid + if not self.perform_verifications(form, user, club, fee): + return self.form_invalid(form) # Now, all is fine, the membership can be created. @@ -671,15 +702,6 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): # Credit note before the membership is created. if credit_amount > 0: - if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"): - if not last_name: - form.add_error('last_name', _("This field is required.")) - if not first_name: - form.add_error('first_name', _("This field is required.")) - if not bank and credit_type.special_type == "Chèque": - form.add_error('bank', _("This field is required.")) - return self.form_invalid(form) - transaction = SpecialTransaction( source=credit_type, destination=user.note, diff --git a/apps/permission/tests/test_rights_page.py b/apps/permission/tests/test_rights_page.py new file mode 100644 index 00000000..da80bf09 --- /dev/null +++ b/apps/permission/tests/test_rights_page.py @@ -0,0 +1,44 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from member.models import Membership, Club +from permission.models import Role + + +class TestRightsPage(TestCase): + """ + Display the rights page. + """ + fixtures = ("initial",) + + def test_anonymous_rights_page(self): + """ + Check that we can properly see the rights page even if we are not connected. + We can't nethertheless see the club managers. + """ + response = self.client.get(reverse("permission:rights")) + self.assertEqual(response.status_code, 200) + self.assertFalse("special_memberships_table" in response.context) + self.assertFalse("superusers" in response.context) + + def test_authenticated_rights_page(self): + """ + Connect to the note and check that the club mangers are also displayed. + """ + user = User.objects.create_superuser( + username="ploptoto", + password="totototo", + email="toto@example.com", + ) + self.client.force_login(user) + membership = Membership.objects.create(user=user, club=Club.objects.get(name="BDE")) + membership.roles.add(Role.objects.get(name="Respo info")) + membership.save() + + response = self.client.get(reverse("permission:rights")) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.context["special_memberships_table"]) + self.assertIsNotNone(response.context["superusers"]) diff --git a/apps/treasury/migrations/0002_invoice_remove_png_extension.py b/apps/treasury/migrations/0002_invoice_remove_png_extension.py new file mode 100644 index 00000000..e908d699 --- /dev/null +++ b/apps/treasury/migrations/0002_invoice_remove_png_extension.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.16 on 2020-09-06 13:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('treasury', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='invoice', + name='bde', + field=models.CharField(choices=[('Saperlistpopette', 'Saper[list]popette'), ('Finalist', 'Fina[list]'), ('Listorique', '[List]orique'), ('Satellist', 'Satel[list]'), ('Monopolist', 'Monopo[list]'), ('Kataclist', 'Katac[list]')], default='Saperlistpopette', max_length=32, verbose_name='BDE'), + ), + ] diff --git a/apps/treasury/models.py b/apps/treasury/models.py index 762c7bb5..e57496ec 100644 --- a/apps/treasury/models.py +++ b/apps/treasury/models.py @@ -25,14 +25,14 @@ class Invoice(models.Model): bde = models.CharField( max_length=32, - default='Saperlistpopette.png', + default='Saperlistpopette', choices=( - ('Saperlistpopette.png', 'Saper[list]popette'), - ('Finalist.png', 'Fina[list]'), - ('Listorique.png', '[List]orique'), - ('Satellist.png', 'Satel[list]'), - ('Monopolist.png', 'Monopo[list]'), - ('Kataclist.png', 'Katac[list]'), + ('Saperlistpopette', 'Saper[list]popette'), + ('Finalist', 'Fina[list]'), + ('Listorique', '[List]orique'), + ('Satellist', 'Satel[list]'), + ('Monopolist', 'Monopo[list]'), + ('Kataclist', 'Katac[list]'), ), verbose_name=_("BDE"), ) diff --git a/apps/treasury/static/img/Finalist_bg.jpg b/apps/treasury/static/img/Finalist_bg.jpg new file mode 100644 index 00000000..8f1fe6c7 Binary files /dev/null and b/apps/treasury/static/img/Finalist_bg.jpg differ diff --git a/apps/treasury/static/img/Kataclist_bg.jpg b/apps/treasury/static/img/Kataclist_bg.jpg new file mode 100644 index 00000000..fa3888fa Binary files /dev/null and b/apps/treasury/static/img/Kataclist_bg.jpg differ diff --git a/apps/treasury/static/img/Listorique_bg.jpg b/apps/treasury/static/img/Listorique_bg.jpg new file mode 100644 index 00000000..6400da23 Binary files /dev/null and b/apps/treasury/static/img/Listorique_bg.jpg differ diff --git a/apps/treasury/static/img/Monopolist_bg.jpg b/apps/treasury/static/img/Monopolist_bg.jpg new file mode 100644 index 00000000..09380801 Binary files /dev/null and b/apps/treasury/static/img/Monopolist_bg.jpg differ diff --git a/apps/treasury/static/img/Saperlistpopette_bg.jpg b/apps/treasury/static/img/Saperlistpopette_bg.jpg new file mode 100644 index 00000000..b88884ac Binary files /dev/null and b/apps/treasury/static/img/Saperlistpopette_bg.jpg differ diff --git a/apps/treasury/static/img/Satellist_bg.jpg b/apps/treasury/static/img/Satellist_bg.jpg new file mode 100644 index 00000000..825114f7 Binary files /dev/null and b/apps/treasury/static/img/Satellist_bg.jpg differ diff --git a/apps/treasury/templates/treasury/invoice_sample.tex b/apps/treasury/templates/treasury/invoice_sample.tex index c9bb92b8..a6b263d6 100644 --- a/apps/treasury/templates/treasury/invoice_sample.tex +++ b/apps/treasury/templates/treasury/invoice_sample.tex @@ -1,22 +1,14 @@ {% load escape_tex %} +\documentclass[a4paper,11pt]{article} -\nonstopmode -\documentclass[11pt]{article} - -\usepackage[french]{babel} -\usepackage[T1]{fontenc} -\usepackage[utf8]{inputenc} -\usepackage[a4paper]{geometry} -%\usepackage{bera} +\usepackage{fontspec} +\usepackage{geometry} \usepackage{graphicx} \usepackage{fancyhdr} \usepackage{fp} -\usepackage{transparent} \usepackage{eso-pic} \usepackage{ifthen} -\DeclareUnicodeCharacter{00B0}{$^\circ$} - \def\TVA{0} % Taux de la TVA \def\TotalHT{0} @@ -60,7 +52,7 @@ \parbox[b][\paperheight]{\paperwidth}{% \vfill \centering - {\transparent{0.1}\includegraphics[width=\textwidth]{../../apps/treasury/static/img/{{ obj.bde }}}}% + \includegraphics[width=\textwidth]{../../apps/treasury/static/img/{{ obj.bde }}_bg.jpg}; \vfill } } diff --git a/apps/treasury/tests/test_treasury.py b/apps/treasury/tests/test_treasury.py index 580cfb2d..0d6e5d62 100644 --- a/apps/treasury/tests/test_treasury.py +++ b/apps/treasury/tests/test_treasury.py @@ -1,6 +1,5 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from unittest import skip from django.contrib.auth.models import User from django.core.exceptions import ValidationError @@ -143,7 +142,6 @@ class TestInvoices(TestCase): self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200) self.assertFalse(Invoice.objects.filter(pk=self.invoice.id).exists()) - @skip("LaTeX is buggy in the CI") def test_invoice_render_pdf(self): """ Generate the PDF file of an invoice. diff --git a/apps/treasury/views.py b/apps/treasury/views.py index 2bddf319..7e9ecc51 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -209,7 +209,7 @@ class InvoiceRenderView(LoginRequiredMixin, View): # The file has to be rendered twice for ignored in range(2): error = subprocess.Popen( - ["pdflatex", "invoice-{}.tex".format(pk)], + ["xelatex", "-interaction=nonstopmode", "invoice-{}.tex".format(pk)], cwd=tmp_dir, stdin=open(os.devnull, "r"), stderr=open(os.devnull, "wb"), diff --git a/apps/wei/templates/wei/weilist_sample.tex b/apps/wei/templates/wei/weilist_sample.tex index 1ab3b76d..f1b7fbd4 100644 --- a/apps/wei/templates/wei/weilist_sample.tex +++ b/apps/wei/templates/wei/weilist_sample.tex @@ -1,15 +1,11 @@ -\documentclass[landscape,10pt]{article} - -\usepackage[utf8]{inputenc} -\usepackage[T1]{fontenc} -\usepackage[french]{babel} +\documentclass[a4paper,landscape,10pt]{article} +\usepackage{fontspec} \usepackage[margin=1.5cm]{geometry} -\usepackage{lmodern} \begin{document} \begin{center} -\huge{Liste des inscrits \og {{ wei.name }} \fg{}} +\huge{Liste des inscrits « {{ wei.name }} »} {% if bus %} \LARGE{Bus {{ bus.name|safe }}} diff --git a/apps/wei/tests/test_wei_registration.py b/apps/wei/tests/test_wei_registration.py index 1e9036f0..e1724419 100644 --- a/apps/wei/tests/test_wei_registration.py +++ b/apps/wei/tests/test_wei_registration.py @@ -690,7 +690,7 @@ class TestWEIRegistration(TestCase): """ with open("/dev/null", "wb") as devnull: return subprocess.call( - ["which", "pdflatex"], + ["which", "xelatex"], stdout=devnull, stderr=devnull, ) == 0 diff --git a/apps/wei/views.py b/apps/wei/views.py index 7720829c..ced85473 100644 --- a/apps/wei/views.py +++ b/apps/wei/views.py @@ -1103,7 +1103,7 @@ class MemberListRenderView(LoginRequiredMixin, View): with open(os.devnull, "wb") as devnull: error = subprocess.Popen( - ["pdflatex", "{}/wei-list.tex".format(tmp_dir)], + ["xelatex", "-interaction=nonstopmode", "{}/wei-list.tex".format(tmp_dir)], cwd=tmp_dir, stderr=devnull, stdout=devnull, diff --git a/tox.ini b/tox.ini index eff97a2b..1b0f4b55 100644 --- a/tox.ini +++ b/tox.ini @@ -42,7 +42,7 @@ exclude = .cache, .eggs, *migrations* -max-complexity = 10 +max-complexity = 15 max-line-length = 160 import-order-style = google application-import-names = flake8