\ No newline at end of file
diff --git a/apps/activity/views.py b/apps/activity/views.py
index cac7f183..fd218db5 100644
--- a/apps/activity/views.py
+++ b/apps/activity/views.py
@@ -1,30 +1,45 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
-from datetime import datetime, timezone
-
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import PermissionDenied
from django.db.models import F, Q
from django.urls import reverse_lazy
-from django.views.generic import CreateView, DetailView, UpdateView, TemplateView
+from django.utils import timezone
from django.utils.translation import gettext_lazy as _
+from django.views.generic import DetailView, TemplateView, UpdateView
from django_tables2.views import SingleTableView
-from note.models import NoteUser, Alias, NoteSpecial
+from note.models import Alias, NoteSpecial, NoteUser
from permission.backends import PermissionBackend
-from permission.views import ProtectQuerysetMixin
+from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import ActivityForm, GuestForm
-from .models import Activity, Guest, Entry
-from .tables import ActivityTable, GuestTable, EntryTable
+from .models import Activity, Entry, Guest
+from .tables import ActivityTable, EntryTable, GuestTable
-class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
+class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
+ """
+ View to create a new Activity
+ """
model = Activity
form_class = ActivityForm
extra_context = {"title": _("Create new activity")}
+ def get_sample_object(self):
+ return Activity(
+ name="",
+ description="",
+ creater=self.request.user,
+ activity_type_id=1,
+ organizer_id=1,
+ attendees_club_id=1,
+ date_start=timezone.now(),
+ date_end=timezone.now(),
+ )
+
def form_valid(self, form):
form.instance.creater = self.request.user
return super().form_valid(form)
@@ -35,6 +50,9 @@ class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
+ """
+ Displays all Activities, and classify if they are on-going or upcoming ones.
+ """
model = Activity
table_class = ActivityTable
ordering = ('-date_start',)
@@ -46,16 +64,24 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- upcoming_activities = Activity.objects.filter(date_end__gt=datetime.now())
+ upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now())
context['upcoming'] = ActivityTable(
data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")),
prefix='upcoming-',
)
+ started_activities = Activity.objects\
+ .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
+ .filter(open=True, valid=True).all()
+ context["started_activities"] = started_activities
+
return context
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
+ """
+ Shows details about one activity. Add guest to context
+ """
model = Activity
context_object_name = "activity"
extra_context = {"title": _("Activity detail")}
@@ -67,12 +93,15 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view")))
context["guests"] = table
- context["activity_started"] = datetime.now(timezone.utc) > self.object.date_start
+ context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
return context
class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
+ """
+ Updates one Activity
+ """
model = Activity
form_class = ActivityForm
extra_context = {"title": _("Update activity")}
@@ -81,10 +110,23 @@ class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
-class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
+class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
+ """
+ Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm`
+ """
model = Guest
form_class = GuestForm
- template_name = "activity/activity_invite.html"
+ template_name = "activity/activity_form.html"
+
+ def get_sample_object(self):
+ """ Creates a standart Guest binds to the Activity"""
+ activity = Activity.objects.get(pk=self.kwargs["pk"])
+ return Guest(
+ activity=activity,
+ first_name="",
+ last_name="",
+ inviter=self.request.user.note,
+ )
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -96,6 +138,7 @@ class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
form = super().get_form(form_class)
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
.get(pk=self.kwargs["pk"])
+ form.fields["inviter"].initial = self.request.user.note
return form
def form_valid(self, form):
@@ -108,57 +151,115 @@ class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
class ActivityEntryView(LoginRequiredMixin, TemplateView):
+ """
+ Manages entry to an activity
+ """
template_name = "activity/activity_entry.html"
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
+ def dispatch(self, request, *args, **kwargs):
+ """
+ Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
+ it is closed or doesn't manage entries.
+ """
+ activity = Activity.objects.get(pk=self.kwargs["pk"])
- activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
- .get(pk=self.kwargs["pk"])
- context["activity"] = activity
+ sample_entry = Entry(activity=activity, note=self.request.user.note)
+ if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry):
+ raise PermissionDenied(_("You are not allowed to display the entry interface for this activity."))
- matched = []
+ if not activity.activity_type.manage_entries:
+ raise PermissionDenied(_("This activity does not support activity entries."))
- pattern = "^$"
- if "search" in self.request.GET:
- pattern = self.request.GET["search"]
+ if not activity.open:
+ raise PermissionDenied(_("This activity is closed."))
+ return super().dispatch(request, *args, **kwargs)
- if not pattern:
- pattern = "^$"
-
- if pattern[0] != "^":
- pattern = "^" + pattern
+ def get_invited_guest(self, activity):
+ """
+ Retrieves all Guests to the activity
+ """
guest_qs = Guest.objects\
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
- .filter(Q(first_name__regex=pattern) | Q(last_name__regex=pattern)
- | Q(inviter__alias__name__regex=pattern)
- | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern))) \
+ .filter(activity=activity)\
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
- .distinct()[:20]
- for guest in guest_qs:
- guest.type = "Invité"
- matched.append(guest)
+ .order_by('last_name', 'first_name').distinct()
+ if "search" in self.request.GET and self.request.GET["search"]:
+ pattern = self.request.GET["search"]
+ if pattern[0] != "^":
+ pattern = "^" + pattern
+ guest_qs = guest_qs.filter(
+ Q(first_name__regex=pattern)
+ | Q(last_name__regex=pattern)
+ | Q(inviter__alias__name__regex=pattern)
+ | Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern))
+ )
+ else:
+ guest_qs = guest_qs.none()
+ return guest_qs
+
+ def get_invited_note(self, activity):
+ """
+ Retrieves all Note that can attend the activity,
+ they need to have an up-to-date membership in the attendees_club.
+ """
note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"),
first_name=F("note__noteuser__user__first_name"),
username=F("note__noteuser__user__username"),
note_name=F("name"),
- balance=F("note__balance"))\
- .filter(Q(note__polymorphic_ctype__model="noteuser")
- & (Q(note__noteuser__user__first_name__regex=pattern)
- | Q(note__noteuser__user__last_name__regex=pattern)
- | Q(name__regex=pattern)
- | Q(normalized_name__regex=Alias.normalize(pattern)))) \
- .filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))
- if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql_psycopg2':
+ balance=F("note__balance"))
+
+ # Keep only users that have a note
+ note_qs = note_qs.filter(note__noteuser__isnull=False)
+
+ # Keep only members
+ note_qs = note_qs.filter(
+ note__noteuser__user__memberships__club=activity.attendees_club,
+ note__noteuser__user__memberships__date_start__lte=timezone.now(),
+ note__noteuser__user__memberships__date_end__gte=timezone.now(),
+ )
+
+ # Filter with permission backend
+ note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))
+
+ if "search" in self.request.GET and self.request.GET["search"]:
+ pattern = self.request.GET["search"]
+ note_qs = note_qs.filter(
+ Q(note__noteuser__user__first_name__regex=pattern)
+ | Q(note__noteuser__user__last_name__regex=pattern)
+ | Q(name__regex=pattern)
+ | Q(normalized_name__regex=Alias.normalize(pattern))
+ )
+ else:
+ note_qs = note_qs.none()
+
+ if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql':
note_qs = note_qs.distinct('note__pk')[:20]
else:
# SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
# have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
# In production mode, please use PostgreSQL.
note_qs = note_qs.distinct()[:20]
- for note in note_qs:
+ return note_qs
+
+ def get_context_data(self, **kwargs):
+ """
+ Query the list of Guest and Note to the activity and add information to makes entry with JS.
+ """
+ context = super().get_context_data(**kwargs)
+
+ activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
+ .distinct().get(pk=self.kwargs["pk"])
+ context["activity"] = activity
+
+ matched = []
+
+ for guest in self.get_invited_guest(activity):
+ guest.type = "Invité"
+ matched.append(guest)
+
+ for note in self.get_invited_note(activity):
note.type = "Adhérent"
note.activity = activity
matched.append(note)
@@ -172,8 +273,11 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
- context["activities_open"] = Activity.objects.filter(open=True).filter(
- PermissionBackend.filter_queryset(self.request.user, Activity, "view")).filter(
- PermissionBackend.filter_queryset(self.request.user, Activity, "change")).all()
+ activities_open = Activity.objects.filter(open=True).filter(
+ PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all()
+ context["activities_open"] = [a for a in activities_open
+ if PermissionBackend.check_perm(self.request.user,
+ "activity.add_entry",
+ Entry(activity=a, note=self.request.user.note,))]
return context
diff --git a/apps/api/urls.py b/apps/api/urls.py
index 03d6bd68..9b4d44de 100644
--- a/apps/api/urls.py
+++ b/apps/api/urls.py
@@ -1,21 +1,16 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
+from django.conf import settings
from django.conf.urls import url, include
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import routers, serializers
-from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ReadOnlyModelViewSet
-from activity.api.urls import register_activity_urls
from api.viewsets import ReadProtectedModelViewSet
-from member.api.urls import register_members_urls
-from note.api.urls import register_note_urls
-from treasury.api.urls import register_treasury_urls
-from logs.api.urls import register_logs_urls
-from permission.api.urls import register_permission_urls
-from wei.api.urls import register_wei_urls
+from note.models import Alias
class UserSerializer(serializers.ModelSerializer):
@@ -52,9 +47,47 @@ class UserViewSet(ReadProtectedModelViewSet):
"""
queryset = User.objects.all()
serializer_class = UserSerializer
- filter_backends = [DjangoFilterBackend, SearchFilter]
+ filter_backends = [DjangoFilterBackend]
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
- search_fields = ['$username', '$first_name', '$last_name', ]
+
+ def get_queryset(self):
+ queryset = super().get_queryset().order_by("username")
+
+ if "search" in self.request.GET:
+ pattern = self.request.GET["search"]
+
+ # We match first a user by its username, then if an alias is matched without normalization
+ # And finally if the normalized pattern matches a normalized alias.
+ queryset = queryset.filter(
+ username__iregex="^" + pattern
+ ).union(
+ queryset.filter(
+ Q(note__alias__name__iregex="^" + pattern)
+ & ~Q(username__iregex="^" + pattern)
+ ), all=True).union(
+ queryset.filter(
+ Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
+ & ~Q(note__alias__name__iregex="^" + pattern)
+ & ~Q(username__iregex="^" + pattern)
+ ),
+ all=True).union(
+ queryset.filter(
+ Q(note__alias__normalized_name__iregex="^" + pattern.lower())
+ & ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
+ & ~Q(note__alias__name__iregex="^" + pattern)
+ & ~Q(username__iregex="^" + pattern)
+ ),
+ all=True).union(
+ queryset.filter(
+ (Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern))
+ & ~Q(note__alias__normalized_name__iregex="^" + pattern.lower())
+ & ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
+ & ~Q(note__alias__name__iregex="^" + pattern)
+ & ~Q(username__iregex="^" + pattern)
+ ),
+ all=True)
+
+ return queryset
# This ViewSet is the only one that is accessible from all authenticated users!
@@ -73,13 +106,34 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
router = routers.DefaultRouter()
router.register('models', ContentTypeViewSet)
router.register('user', UserViewSet)
-register_members_urls(router, 'members')
-register_activity_urls(router, 'activity')
-register_note_urls(router, 'note')
-register_treasury_urls(router, 'treasury')
-register_permission_urls(router, 'permission')
-register_logs_urls(router, 'logs')
-register_wei_urls(router, 'wei')
+
+if "member" in settings.INSTALLED_APPS:
+ from member.api.urls import register_members_urls
+ register_members_urls(router, 'members')
+
+if "member" in settings.INSTALLED_APPS:
+ from activity.api.urls import register_activity_urls
+ register_activity_urls(router, 'activity')
+
+if "note" in settings.INSTALLED_APPS:
+ from note.api.urls import register_note_urls
+ register_note_urls(router, 'note')
+
+if "treasury" in settings.INSTALLED_APPS:
+ from treasury.api.urls import register_treasury_urls
+ register_treasury_urls(router, 'treasury')
+
+if "permission" in settings.INSTALLED_APPS:
+ from permission.api.urls import register_permission_urls
+ register_permission_urls(router, 'permission')
+
+if "logs" in settings.INSTALLED_APPS:
+ from logs.api.urls import register_logs_urls
+ register_logs_urls(router, 'logs')
+
+if "wei" in settings.INSTALLED_APPS:
+ from wei.api.urls import register_wei_urls
+ register_wei_urls(router, 'wei')
app_name = 'api'
diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py
index 6e0cb6b8..01fc7998 100644
--- a/apps/api/viewsets.py
+++ b/apps/api/viewsets.py
@@ -4,7 +4,7 @@
from django.contrib.contenttypes.models import ContentType
from permission.backends import PermissionBackend
from rest_framework import viewsets
-from note_kfet.middlewares import get_current_authenticated_user
+from note_kfet.middlewares import get_current_session
class ReadProtectedModelViewSet(viewsets.ModelViewSet):
@@ -17,8 +17,9 @@ class ReadProtectedModelViewSet(viewsets.ModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self):
- user = get_current_authenticated_user()
- return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))
+ user = self.request.user
+ get_current_session().setdefault("permission_mask", 42)
+ return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet):
@@ -31,5 +32,6 @@ class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet):
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
def get_queryset(self):
- user = get_current_authenticated_user()
- return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))
+ user = self.request.user
+ get_current_session().setdefault("permission_mask", 42)
+ return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py
index b3b9b166..4160d609 100644
--- a/apps/logs/api/views.py
+++ b/apps/logs/api/views.py
@@ -19,5 +19,5 @@ class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
serializer_class = ChangelogSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
- ordering_fields = ['timestamp', ]
- ordering = ['-timestamp', ]
+ ordering_fields = ['timestamp', 'id', ]
+ ordering = ['-id', ]
diff --git a/apps/logs/signals.py b/apps/logs/signals.py
index 68bf95c0..2d443d13 100644
--- a/apps/logs/signals.py
+++ b/apps/logs/signals.py
@@ -23,6 +23,9 @@ EXCLUDED = [
'cas_server.userattributes',
'contenttypes.contenttype',
'logs.changelog', # Never remove this line
+ 'mailer.dontsendentry',
+ 'mailer.message',
+ 'mailer.messagelog',
'migrations.migration',
'note.note' # We only store the subclasses
'note.transaction',
@@ -78,19 +81,30 @@ def save_object(sender, instance, **kwargs):
if instance.last_login != previous.last_login:
return
- # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
+ changed_fields = '__all__'
+ if previous:
+ # On ne garde que les champs modifiés
+ changed_fields = []
+ for field in instance._meta.fields:
+ if field.name.endswith("_ptr"):
+ # A field ending with _ptr is a OneToOneRel with a subclass, e.g. NoteClub.note_ptr -> Note
+ continue
+ if getattr(instance, field.name) != getattr(previous, field.name):
+ changed_fields.append(field.name)
+
+ if len(changed_fields) == 0:
+ # Pas de log s'il n'y a pas de modification
+ return
+
+ # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles avec uniquement les champs modifiés
class CustomSerializer(ModelSerializer):
class Meta:
model = instance.__class__
- fields = '__all__'
+ fields = changed_fields
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else None
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
- if previous_json == instance_json:
- # Pas de log s'il n'y a pas de modification
- return
-
Changelog.objects.create(user=user,
ip=ip,
model=ContentType.objects.get_for_model(instance),
diff --git a/apps/member/admin.py b/apps/member/admin.py
index bd29557b..4cc2d0bf 100644
--- a/apps/member/admin.py
+++ b/apps/member/admin.py
@@ -17,6 +17,7 @@ class ProfileInline(admin.StackedInline):
Inline user profile in user admin
"""
model = Profile
+ form = ProfileForm
can_delete = False
@@ -25,7 +26,6 @@ class CustomUserAdmin(UserAdmin):
inlines = (ProfileInline,)
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
list_select_related = ('profile',)
- form = ProfileForm
def get_inline_instances(self, request, obj=None):
"""
diff --git a/apps/member/forms.py b/apps/member/forms.py
index 50fa9c47..a5d571b6 100644
--- a/apps/member/forms.py
+++ b/apps/member/forms.py
@@ -4,6 +4,8 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
+from django.forms import CheckboxSelectMultiple
+from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, Alias
from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput
@@ -36,6 +38,20 @@ class ProfileForm(forms.ModelForm):
"""
A form for the extras field provided by the :model:`member.Profile` model.
"""
+ report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
+
+ last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
+
+ def clean_promotion(self):
+ promotion = self.cleaned_data["promotion"]
+ if promotion > timezone.now().year:
+ self.add_error("promotion", _("You can't register to the note if you come from the future."))
+ return promotion
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['address'].widget.attrs.update({"placeholder": "4 avenue des Sciences, 91190 GIF-SUR-YVETTE"})
+ self.fields['promotion'].widget.attrs.update({"max": timezone.now().year})
def save(self, commit=True):
if not self.instance.section or (("department" in self.changed_data
@@ -49,6 +65,19 @@ class ProfileForm(forms.ModelForm):
exclude = ('user', 'email_confirmed', 'registration_valid', )
+class ImageForm(forms.Form):
+ """
+ Form used for the js interface for profile picture
+ """
+ image = forms.ImageField(required=False,
+ label=_('select an image'),
+ help_text=_('Maximal size: 2MB'))
+ x = forms.FloatField(widget=forms.HiddenInput())
+ y = forms.FloatField(widget=forms.HiddenInput())
+ width = forms.FloatField(widget=forms.HiddenInput())
+ height = forms.FloatField(widget=forms.HiddenInput())
+
+
class ClubForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
@@ -151,6 +180,7 @@ class MembershipRolesForm(forms.ModelForm):
roles = forms.ModelMultipleChoiceField(
queryset=Role.objects.filter(weirole=None).all(),
label=_("Roles"),
+ widget=CheckboxSelectMultiple(),
)
class Meta:
diff --git a/apps/member/models.py b/apps/member/models.py
index efd8bf8c..b17f1f09 100644
--- a/apps/member/models.py
+++ b/apps/member/models.py
@@ -8,11 +8,15 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models
+from django.db.models import Q
from django.template import loader
from django.urls import reverse, reverse_lazy
+from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext_lazy as _
+from phonenumber_field.modelfields import PhoneNumberField
+from permission.models import Role
from registration.tokens import email_validation_token
from note.models import MembershipTransaction
@@ -30,7 +34,7 @@ class Profile(models.Model):
on_delete=models.CASCADE,
)
- phone_number = models.CharField(
+ phone_number = PhoneNumberField(
verbose_name=_('phone number'),
max_length=50,
blank=True,
@@ -68,7 +72,7 @@ class Profile(models.Model):
]
)
- promotion = models.PositiveIntegerField(
+ promotion = models.PositiveSmallIntegerField(
null=True,
default=datetime.date.today().year,
verbose_name=_("promotion"),
@@ -88,6 +92,39 @@ class Profile(models.Model):
default=False,
)
+ ml_events_registration = models.CharField(
+ blank=True,
+ null=True,
+ default=None,
+ max_length=2,
+ choices=[
+ (None, _("No")),
+ ('fr', _("Yes (receive them in french)")),
+ ('en', _("Yes (receive them in english)")),
+ ],
+ verbose_name=_("Register on the mailing list to stay informed of the events of the campus (1 mail/week)"),
+ )
+
+ ml_sport_registration = models.BooleanField(
+ default=False,
+ verbose_name=_("Register on the mailing list to stay informed of the sport events of the campus (1 mail/week)"),
+ )
+
+ ml_art_registration = models.BooleanField(
+ default=False,
+ verbose_name=_("Register on the mailing list to stay informed of the art events of the campus (1 mail/week)"),
+ )
+
+ report_frequency = models.PositiveSmallIntegerField(
+ verbose_name=_("report frequency (in days)"),
+ default=0,
+ )
+
+ last_report = models.DateTimeField(
+ verbose_name=_("last report date"),
+ default=timezone.now,
+ )
+
email_confirmed = models.BooleanField(
verbose_name=_("email confirmed"),
default=False,
@@ -128,18 +165,28 @@ class Profile(models.Model):
indexes = [models.Index(fields=['user'])]
def get_absolute_url(self):
- return reverse('user_detail', args=(self.pk,))
+ return reverse('member:user_detail', args=(self.user_id,))
+
+ def __str__(self):
+ return str(self.user)
def send_email_validation_link(self):
- subject = _("Activate your Note Kfet account")
- message = loader.render_to_string('registration/mails/email_validation_email.html',
+ subject = "[Note Kfet] " + str(_("Activate your Note Kfet account"))
+ message = loader.render_to_string('registration/mails/email_validation_email.txt',
{
'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"),
'token': email_validation_token.make_token(self.user),
'uid': urlsafe_base64_encode(force_bytes(self.user.pk)),
})
- self.user.email_user(subject, message)
+ html = loader.render_to_string('registration/mails/email_validation_email.html',
+ {
+ 'user': self.user,
+ 'domain': os.getenv("NOTE_URL", "note.example.com"),
+ 'token': email_validation_token.make_token(self.user),
+ 'uid': urlsafe_base64_encode(force_bytes(self.user.pk)),
+ })
+ self.user.email_user(subject, message, html_message=html)
class Club(models.Model):
@@ -195,17 +242,14 @@ class Club(models.Model):
blank=True,
null=True,
verbose_name=_('membership start'),
- help_text=_('How long after January 1st the members can renew '
- 'their membership.'),
+ help_text=_('Date from which the members can renew their membership.'),
)
membership_end = models.DateField(
blank=True,
null=True,
verbose_name=_('membership end'),
- help_text=_('How long the membership can last after January 1st '
- 'of the next year after members can renew their '
- 'membership.'),
+ help_text=_('Maximal date of a membership, after which members must renew it.'),
)
def update_membership_dates(self):
@@ -294,13 +338,33 @@ class Membership(models.Model):
else:
return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
+ def renew(self):
+ if Membership.objects.filter(
+ user=self.user,
+ club=self.club,
+ date_start__gte=self.club.membership_start,
+ ).exists():
+ # Membership is already renewed
+ return
+ new_membership = Membership(
+ user=self.user,
+ club=self.club,
+ date_start=max(self.date_end + datetime.timedelta(days=1), self.club.membership_start),
+ )
+ if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
+ new_membership._force_renew_parent = True
+ if hasattr(self, '_soge') and self._soge:
+ new_membership._soge = True
+ if hasattr(self, '_force_save') and self._force_save:
+ new_membership._force_save = True
+ new_membership.save()
+ new_membership.roles.set(self.roles.all())
+ new_membership.save()
+
def save(self, *args, **kwargs):
"""
Calculate fee and end date before saving the membership and creating the transaction if needed.
"""
- if self.club.parent_club is not None:
- if not Membership.objects.filter(user=self.user, club=self.club.parent_club).exists():
- raise ValidationError(_('User is not a member of the parent club') + ' ' + self.club.parent_club.name)
if self.pk:
for role in self.roles.all():
@@ -320,6 +384,55 @@ class Membership(models.Model):
).exists():
raise ValidationError(_('User is already a member of the club'))
+ if self.club.parent_club is not None and not self.pk:
+ # Check that the user is already a member of the parent club if the membership is created
+ if not Membership.objects.filter(
+ user=self.user,
+ club=self.club.parent_club,
+ 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()
+ else:
+ raise ValidationError(_('User is not a member of the parent club')
+ + ' ' + self.club.parent_club.name)
+
if self.user.profile.paid:
self.fee = self.club.membership_fee_paid
else:
@@ -353,8 +466,9 @@ class Membership(models.Model):
reason="Adhésion " + self.club.name,
)
transaction._force_save = True
- print(hasattr(self, '_soge'))
- if hasattr(self, '_soge') and "treasury" in settings.INSTALLED_APPS:
+ if hasattr(self, '_soge') and "treasury" in settings.INSTALLED_APPS\
+ and (self.club.name == "BDE" or self.club.name == "Kfet"
+ or ("wei" in settings.INSTALLED_APPS and hasattr(self.club, "weiclub") and self.club.weiclub)):
# If the soge pays, then the transaction is unvalidated in a first time, then submitted for control
# to treasurers.
transaction.valid = False
diff --git a/apps/member/static/member/js/alias.js b/apps/member/static/member/js/alias.js
new file mode 100644
index 00000000..2d652dde
--- /dev/null
+++ b/apps/member/static/member/js/alias.js
@@ -0,0 +1,43 @@
+/**
+ * On form submit, create a new alias
+ */
+function create_alias (e) {
+ // Do not submit HTML form
+ e.preventDefault();
+
+ // Get data and send to API
+ const formData = new FormData(e.target);
+ $.post("/api/note/alias/", {
+ "csrfmiddlewaretoken": formData.get("csrfmiddlewaretoken"),
+ "name": formData.get("name"),
+ "note": formData.get("note")
+ }).done(function () {
+ // Reload table
+ $("#alias_table").load(location.pathname + " #alias_table");
+ addMsg("Alias ajouté", "success");
+ }).fail(function (xhr, _textStatus, _error) {
+ errMsg(xhr.responseJSON);
+ });
+}
+
+/**
+ * On click of "delete", delete the alias
+ * @param Integer button_id Alias id to remove
+ */
+function delete_button (button_id) {
+ $.ajax({
+ url: "/api/note/alias/" + button_id + "/",
+ method: "DELETE",
+ headers: { "X-CSRFTOKEN": CSRF_TOKEN }
+ }).done(function () {
+ addMsg('Alias supprimé', 'success');
+ $("#alias_table").load(location.pathname + " #alias_table");
+ }).fail(function (xhr, _textStatus, _error) {
+ errMsg(xhr.responseJSON);
+ });
+}
+
+$(document).ready(function () {
+ // Attach event
+ document.getElementById("form_alias").addEventListener("submit", create_alias);
+})
\ No newline at end of file
diff --git a/apps/member/tables.py b/apps/member/tables.py
index 1247da00..8c979f08 100644
--- a/apps/member/tables.py
+++ b/apps/member/tables.py
@@ -1,6 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
-from datetime import datetime
+
+from datetime import date
import django_tables2 as tables
from django.contrib.auth.models import User
@@ -38,19 +39,22 @@ class UserTable(tables.Table):
"""
List all users.
"""
- section = tables.Column(accessor='profile.section')
+ alias = tables.Column()
- balance = tables.Column(accessor='note.balance', verbose_name=_("Balance"))
+ section = tables.Column(accessor='profile__section')
- def render_balance(self, value):
- return pretty_money(value)
+ balance = tables.Column(accessor='note__balance', verbose_name=_("Balance"))
+
+ def render_balance(self, record, value):
+ return pretty_money(value)\
+ if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", record.note) else "—"
class Meta:
attrs = {
'class': 'table table-condensed table-striped table-hover'
}
template_name = 'django_tables2/bootstrap4.html'
- fields = ('last_name', 'first_name', 'username', 'email')
+ fields = ('last_name', 'first_name', 'username', 'alias', 'email')
model = User
row_attrs = {
'class': 'table-row',
@@ -92,26 +96,30 @@ class MembershipTable(tables.Table):
t = pretty_money(value)
# If it is required and if the user has the right, the renew button is displayed.
- if record.club.membership_start is not None:
- if record.date_start < record.club.membership_start: # If the renew is available
- if not Membership.objects.filter(
- club=record.club,
- user=record.user,
- date_start__gte=record.club.membership_start,
- date_end__lte=record.club.membership_end,
- ).exists(): # If the renew is not yet performed
- empty_membership = Membership(
- club=record.club,
- user=record.user,
- date_start=datetime.now().date(),
- date_end=datetime.now().date(),
- fee=0,
+ if record.club.membership_start is not None \
+ and record.date_start < record.club.membership_start:
+ if not Membership.objects.filter(
+ club=record.club,
+ user=record.user,
+ date_start__gte=record.club.membership_start,
+ date_end__lte=record.club.membership_end,
+ ).exists(): # If the renew is not yet performed
+ empty_membership = Membership(
+ club=record.club,
+ user=record.user,
+ date_start=date.today(),
+ date_end=date.today(),
+ fee=0,
+ )
+ if PermissionBackend.check_perm(get_current_authenticated_user(),
+ "member:add_membership", empty_membership): # If the user has right
+ renew_url = reverse_lazy('member:club_renew_membership',
+ kwargs={"pk": record.pk})
+ t = format_html(
+ t + ' ',
+ renew_url=renew_url, text=_("Renew")
)
- if PermissionBackend.check_perm(get_current_authenticated_user(),
- "member:add_membership", empty_membership): # If the user has right
- t = format_html(t + ' {text}',
- url=reverse_lazy('member:club_renew_membership',
- kwargs={"pk": record.pk}), text=_("Renew"))
return t
def render_roles(self, record):
@@ -125,7 +133,7 @@ class MembershipTable(tables.Table):
class Meta:
attrs = {
- 'class': 'table table-condensed table-striped table-hover',
+ 'class': 'table table-condensed table-striped',
'style': 'table-layout: fixed;'
}
template_name = 'django_tables2/bootstrap4.html'
@@ -157,5 +165,5 @@ class ClubManagerTable(tables.Table):
'style': 'table-layout: fixed;'
}
template_name = 'django_tables2/bootstrap4.html'
- fields = ('user', 'user.first_name', 'user.last_name', 'roles', )
+ fields = ('user', 'user__first_name', 'user__last_name', 'roles', )
model = Membership
diff --git a/apps/member/templates/member/add_members.html b/apps/member/templates/member/add_members.html
new file mode 100644
index 00000000..fa0a958c
--- /dev/null
+++ b/apps/member/templates/member/add_members.html
@@ -0,0 +1,75 @@
+{% extends "member/base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load crispy_forms_tags i18n pretty_money %}
+
+{% block profile_content %}
+
+
+ {{ title }}
+
+
+ {% if additional_fee_renewal %}
+
+ {% if renewal %}
+ {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
+ The user is not a member of the club·s {{ clubs }}. An additional fee of {{ pretty_fee }}
+ will be charged to renew automatically the membership in this/these club·s.
+ {% endblocktrans %}
+ {% else %}
+ {% blocktrans trimmed with clubs=clubs_renewal|join:", " pretty_fee=additional_fee_renewal|pretty_money %}
+ This club has parents {{ clubs }}. An additional fee of {{ pretty_fee }}
+ will be charged to adhere automatically to this/these club·s.
+ {% endblocktrans %}
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+{% endblock %}
+
+{% block extrajavascript %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/member/templates/member/base.html b/apps/member/templates/member/base.html
new file mode 100644
index 00000000..e1e9335b
--- /dev/null
+++ b/apps/member/templates/member/base.html
@@ -0,0 +1,184 @@
+{% extends "base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load i18n perms %}
+
+{# Use a fluid-width container #}
+{% block containertype %}container-fluid{% endblock %}
+
+{% block content %}
+
+
+ {% block profile_info %}
+
+
+ {% if user_object %}
+ {% trans "Account #" %}{{ user_object.pk }}
+ {% elif club %}
+ Club {{ club.name }}
+ {% endif %}
+
+ {% if user_object %}
+ {% include "member/includes/profile_info.html" %}
+ {% elif club %}
+ {% include "member/includes/club_info.html" %}
+ {% endif %}
+
+
+
+ {% endblock %}
+
+
+ {% block profile_content %}{% endblock %}
+
+
+ {# Popup to confirm the action of locking the note. Managed by a button #}
+
+
+
+
+
{% trans "Lock note" %}
+
+
+
+ {% blocktrans trimmed %}
+ Are you sure you want to lock this note? This will prevent any transaction that would be performed,
+ until the note is unlocked.
+ {% endblocktrans %}
+ {% if can_force_lock %}
+ {% blocktrans trimmed %}
+ If you use the force mode, the user won't be able to unlock the note by itself.
+ {% endblocktrans %}
+ {% endif %}
+
+
+
+
+
+
+ {# Popup to confirm the action of unlocking the note. Managed by a button #}
+
+
+
+
+
{% trans "Unlock note" %}
+
+
+
+ {% blocktrans trimmed %}
+ Are you sure you want to unlock this note? Transactions will be re-enabled.
+ {% endblocktrans %}
+
+
+ {% if "note.view_note"|has_perm:user_object.note %}
+
{% trans 'balance'|capfirst %}
+
{{ user_object.note.balance | pretty_money }}
+
+
{% trans 'paid'|capfirst %}
+
{{ user_object.profile.paid|yesno }}
+ {% endif %}
+
+
+{% if user_object.pk == user_object.pk %}
+
+ {% trans 'Manage auth token' %}
+
+{% endif %}
\ No newline at end of file
diff --git a/apps/member/templates/member/manage_auth_tokens.html b/apps/member/templates/member/manage_auth_tokens.html
new file mode 100644
index 00000000..473286c1
--- /dev/null
+++ b/apps/member/templates/member/manage_auth_tokens.html
@@ -0,0 +1,36 @@
+{% extends "member/base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load i18n %}
+
+{% block content %}
+
+
À quoi sert un jeton d'authentification ?
+
+ Un jeton vous permet de vous connecter à l'API de la Note Kfet.
+ Il suffit pour cela d'ajouter en en-tête de vos requêtes Authorization: Token <TOKEN>
+ pour pouvoir vous identifier.
+
+ Une documentation de l'API arrivera ultérieurement.
+
+ Ce mail t'a été envoyé parce que le solde de ta Note Kfet {{ note }} est négatif !
+
+
+
+ Ton solde actuel est de {{ note.balance|pretty_money }}.
+
+
+
+ Par ailleurs, le BDE ne sert pas d'alcool aux adhérents dont le solde
+ est inférieur à 0 € depuis plus de 24h.
+
+
+
+ Si tu ne comprends pas ton solde, tu peux consulter ton historique
+ sur ton compte.
+
+
+
+ Tu peux venir recharger ta note rapidement à la Kfet, ou envoyer un mail à
+ la trésorerie du BdE (tresorerie.bde@lists.crans.org)
+ pour payer par virement bancaire.
+
+
+--
+
+ Le BDE
+ {% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
+
+
+
\ No newline at end of file
diff --git a/apps/note/templates/note/mails/negative_balance.txt b/apps/note/templates/note/mails/negative_balance.txt
new file mode 100644
index 00000000..0052995a
--- /dev/null
+++ b/apps/note/templates/note/mails/negative_balance.txt
@@ -0,0 +1,25 @@
+{% load pretty_money %}
+{% load getenv %}
+{% load i18n %}
+
+Bonjour {% if note.user %}{{ note.user }}{% else %}{{ note.club.name }}{% endif %},
+
+Ce mail t'a été envoyé parce que le solde de ta Note Kfet
+{{ note }} est négatif !
+
+Ton solde actuel est de {{ note.balance|pretty_money }}.
+
+Par ailleurs, le BDE ne sert pas d'alcool aux adhérents dont le solde
+est inférieur à 0 € depuis plus de 24h.
+
+Si tu ne comprends pas ton solde, tu peux consulter ton historique
+sur ton compte {{ "NOTE_URL"|getenv }}{% if note.user %}{% url "member:user_detail" pk=note.user.pk %}{% else %}{% url "member:club_detail" pk=note.club.pk %}{% endif %}
+
+Tu peux venir recharger ta note rapidement à la Kfet, ou envoyer un mail à
+la trésorerie du BdE (tresorerie.bde@lists.crans.org) pour payer par
+virement bancaire.
+
+--
+Le BDE
+
+{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
\ No newline at end of file
diff --git a/apps/note/templates/note/mails/negative_notes_report.html b/apps/note/templates/note/mails/negative_notes_report.html
new file mode 100644
index 00000000..49254f5d
--- /dev/null
+++ b/apps/note/templates/note/mails/negative_notes_report.html
@@ -0,0 +1,49 @@
+{% load pretty_money %}
+{% load i18n %}
+
+
+
+
+
+ [Note Kfet] Liste des négatifs
+
+
+
+
+
+
Nom
+
Prénom
+
Pseudo
+
Email
+
Solde
+
Durée
+
+
+
+ {% for note in notes %}
+
+ {% if note.user %}
+
{{ note.user.last_name }}
+
{{ note.user.first_name }}
+
{{ note.user.username }}
+
{{ note.user.email }}
+ {% else %}
+
+
+
{{ note.club.name }}
+
{{ note.club.email }}
+ {% endif %}
+
{{ note.balance|pretty_money }}
+
{{ note.last_negative_duration }}
+
+ {% endfor %}
+
+
+
+--
+
+ Le BDE
+ {% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
+
+
+
\ No newline at end of file
diff --git a/apps/note/templates/note/mails/negative_notes_report.txt b/apps/note/templates/note/mails/negative_notes_report.txt
new file mode 100644
index 00000000..3209fbb8
--- /dev/null
+++ b/apps/note/templates/note/mails/negative_notes_report.txt
@@ -0,0 +1,13 @@
+{% load pretty_money %}
+{% load i18n %}
+
+ Nom | Prénom | Pseudo | Email | Solde | Durée
+---------------------+------------+-----------------+-----------------------------------+----------+-----------
+{% for note in notes %}
+{% if note.user %}{{ note.user.last_name }} | {{ note.user.first_name }} | {{ note.user.username }} | {{ note.user.email }} | {{ note.balance|pretty_money }} | {{ note.last_negative_duration }}{% else %} | | {{ note.club.name }} | {{ note.club.email }} | {{ note.balance|pretty_money }} | {{ note.last_negative_duration }}{% endif %}
+{% endfor %}
+
+--
+Le BDE
+
+{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
\ No newline at end of file
diff --git a/apps/note/templates/note/mails/weekly_report.html b/apps/note/templates/note/mails/weekly_report.html
new file mode 100644
index 00000000..871e09c2
--- /dev/null
+++ b/apps/note/templates/note/mails/weekly_report.html
@@ -0,0 +1,57 @@
+{% load pretty_money %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+
+
+
+
+ [Note Kfet] Rapport de la Note Kfet
+
+
+
+
+
+
+ Bonjour,
+
+
+
+ Vous recevez ce mail car vous avez défini une « Fréquence des rapports » dans la Note.
+ Le premier rapport récapitule toutes vos consommations depuis la création de votre compte.
+ Ensuite, un rapport vous est envoyé à la fréquence demandée seulement si vous avez consommé
+ depuis le dernier rapport.
+ Pour arrêter de recevoir des rapports, il vous suffit de modifier votre profil Note et de
+ mettre la fréquence des rapports à 0 ou -1.
+ Pour toutes suggestions par rapport à ce service, contactez
+ notekfet2020@lists.crans.org.
+
+
+
+ Rapport d'activité de {{ user.first_name }} {{ user.last_name }} (note : {{ user }})
+ depuis le {{ last_report }} jusqu'au {{ now }}.
+
+ {% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %}
+
+
+
+ {% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet. Note that the WEI registration includes the Kfet membership." %}
+
+
+
+ {% trans "Thanks" %},
+
+
+--
+
+ {% trans "The Note Kfet team." %}
+ {% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
+
\ No newline at end of file
diff --git a/templates/registration/mails/email_validation_email.html b/apps/registration/templates/registration/mails/email_validation_email.txt
similarity index 77%
rename from templates/registration/mails/email_validation_email.html
rename to apps/registration/templates/registration/mails/email_validation_email.txt
index 577c1220..5ce48110 100644
--- a/templates/registration/mails/email_validation_email.html
+++ b/apps/registration/templates/registration/mails/email_validation_email.txt
@@ -8,8 +8,9 @@ https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=toke
{% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %}
-{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet." %}
+{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet. Note that the WEI registration includes the Kfet membership." %}
{% trans "Thanks" %},
{% trans "The Note Kfet team." %}
+{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
\ No newline at end of file
diff --git a/apps/registration/views.py b/apps/registration/views.py
index 804c9fa9..bf68a8ed 100644
--- a/apps/registration/views.py
+++ b/apps/registration/views.py
@@ -29,7 +29,7 @@ from .tokens import email_validation_token
class UserCreateView(CreateView):
"""
- Une vue pour inscrire un utilisateur et lui créer un profil
+ A view to create a User and add a Profile
"""
form_class = SignUpForm
@@ -39,8 +39,10 @@ class UserCreateView(CreateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- context["profile_form"] = self.second_form()
+ context["profile_form"] = self.second_form(self.request.POST if self.request.POST else None)
del context["profile_form"].fields["section"]
+ del context["profile_form"].fields["report_frequency"]
+ del context["profile_form"].fields["last_report"]
return context
@@ -179,8 +181,6 @@ class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
| Q(profile__section__iregex=pattern)
| Q(username__iregex="^" + pattern)
)
- else:
- qs = qs.none()
return qs[:20]
diff --git a/apps/scripts b/apps/scripts
index dce51ad2..c1c0a879 160000
--- a/apps/scripts
+++ b/apps/scripts
@@ -1 +1 @@
-Subproject commit dce51ad26134d396d7cbfca7c63bd2ed391dd969
+Subproject commit c1c0a8797179d110ad919912378f05b030f44f61
diff --git a/apps/treasury/admin.py b/apps/treasury/admin.py
index 33224ba7..1db820b2 100644
--- a/apps/treasury/admin.py
+++ b/apps/treasury/admin.py
@@ -4,7 +4,8 @@
from django.contrib import admin
from note_kfet.admin import admin_site
-from .models import RemittanceType, Remittance, SogeCredit
+from .forms import ProductForm
+from .models import RemittanceType, Remittance, SogeCredit, Invoice, Product
@admin.register(RemittanceType, site=admin_site)
@@ -39,3 +40,20 @@ class SogeCreditAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
# Don't create a credit manually
return False
+
+
+class ProductInline(admin.StackedInline):
+ """
+ Inline product in invoice admin
+ """
+ model = Product
+ form = ProductForm
+
+
+@admin.register(Invoice, site=admin_site)
+class InvoiceAdmin(admin.ModelAdmin):
+ """
+ Admin customisation for Invoice
+ """
+ list_display = ('object', 'id', 'bde', 'name', 'date', 'acquitted',)
+ inlines = (ProductInline,)
diff --git a/apps/treasury/forms.py b/apps/treasury/forms.py
index 4c761ef2..38da324d 100644
--- a/apps/treasury/forms.py
+++ b/apps/treasury/forms.py
@@ -1,13 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
-import datetime
-
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms
from django.utils.translation import gettext_lazy as _
-from note_kfet.inputs import DatePickerInput, AmountInput
+from note_kfet.inputs import AmountInput
from .models import Invoice, Product, Remittance, SpecialTransactionProxy
@@ -17,18 +15,25 @@ class InvoiceForm(forms.ModelForm):
Create and generate invoices.
"""
- # Django forms don't support date fields. We have to add it manually
- date = forms.DateField(
- initial=datetime.date.today,
- widget=DatePickerInput()
- )
+ def clean(self):
+ if self.instance and self.instance.locked:
+ for field_name in self.fields:
+ self.cleaned_data[field_name] = getattr(self.instance, field_name)
+ self.errors.clear()
+ return self.cleaned_data
+ return super().clean()
- def clean_date(self):
- self.instance.date = self.data.get("date")
+ def save(self, commit=True):
+ """
+ If the invoice is locked, don't save it
+ """
+ if not self.instance.locked:
+ super().save(commit)
+ return self.instance
class Meta:
model = Invoice
- exclude = ('bde', )
+ exclude = ('bde', 'date', 'tex', )
class ProductForm(forms.ModelForm):
@@ -36,7 +41,11 @@ class ProductForm(forms.ModelForm):
model = Product
fields = '__all__'
widgets = {
- "amount": AmountInput()
+ "amount": AmountInput(
+ attrs={
+ "negative": True,
+ }
+ )
}
@@ -115,6 +124,12 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
"""
Attach a special transaction to a remittance.
"""
+ remittance = forms.ModelChoiceField(
+ queryset=Remittance.objects.none(),
+ label=_("Remittance"),
+ empty_label=_("No attached remittance"),
+ required=False,
+ )
# Since we use a proxy model for special transactions, we add manually the fields related to the transaction
last_name = forms.CharField(label=_("Last name"))
@@ -123,7 +138,7 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
bank = forms.Field(label=_("Bank"))
- amount = forms.IntegerField(label=_("Amount"), min_value=0)
+ amount = forms.IntegerField(label=_("Amount"), min_value=0, widget=AmountInput(), disabled=True, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -133,33 +148,19 @@ class LinkTransactionToRemittanceForm(forms.ModelForm):
self.fields["remittance"].queryset = Remittance.objects.filter(closed=False)
- def clean_last_name(self):
- """
- Replace the first name in the information of the transaction.
- """
- self.instance.transaction.last_name = self.data.get("last_name")
- self.instance.transaction.clean()
+ def clean(self):
+ cleaned_data = super().clean()
+ self.instance.transaction.last_name = cleaned_data["last_name"]
+ self.instance.transaction.first_name = cleaned_data["first_name"]
+ self.instance.transaction.bank = cleaned_data["bank"]
+ return cleaned_data
- def clean_first_name(self):
+ def save(self, commit=True):
"""
- Replace the last name in the information of the transaction.
+ Save the transaction and the remittance.
"""
- self.instance.transaction.first_name = self.data.get("first_name")
- self.instance.transaction.clean()
-
- def clean_bank(self):
- """
- Replace the bank in the information of the transaction.
- """
- self.instance.transaction.bank = self.data.get("bank")
- self.instance.transaction.clean()
-
- def clean_amount(self):
- """
- Replace the amount of the transaction.
- """
- self.instance.transaction.amount = self.data.get("amount")
- self.instance.transaction.clean()
+ self.instance.transaction.save()
+ return super().save(commit)
class Meta:
model = SpecialTransactionProxy
diff --git a/apps/treasury/models.py b/apps/treasury/models.py
index 6cfb55c1..6d5b4021 100644
--- a/apps/treasury/models.py
+++ b/apps/treasury/models.py
@@ -1,11 +1,13 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
-from datetime import datetime
+
+from datetime import date
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
+from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from note.models import NoteSpecial, SpecialTransaction, MembershipTransaction
@@ -54,14 +56,55 @@ class Invoice(models.Model):
)
date = models.DateField(
- default=timezone.now,
- verbose_name=_("Place"),
+ default=date.today,
+ verbose_name=_("Date"),
)
acquitted = models.BooleanField(
verbose_name=_("Acquitted"),
+ default=False,
)
+ locked = models.BooleanField(
+ verbose_name=_("Locked"),
+ help_text=_("An invoice can't be edited when it is locked."),
+ default=False,
+ )
+
+ tex = models.TextField(
+ default="",
+ verbose_name=_("tex source"),
+ )
+
+ def save(self, *args, **kwargs):
+ """
+ When an invoice is generated, we store the tex source.
+ The advantage is to never change the template.
+ Warning: editing this model regenerate the tex source, so be careful.
+ """
+
+ old_invoice = Invoice.objects.filter(id=self.id)
+ if old_invoice.exists():
+ if old_invoice.get().locked:
+ raise ValidationError(_("This invoice is locked and can no longer be edited."))
+
+ products = self.products.all()
+
+ self.place = "Gif-sur-Yvette"
+ self.my_name = "BDE ENS Cachan"
+ self.my_address_street = "4 avenue des Sciences"
+ self.my_city = "91190 Gif-sur-Yvette"
+ self.bank_code = 30003
+ self.desk_code = 3894
+ self.account_number = 37280662
+ self.rib_key = 14
+ self.bic = "SOGEFRPP"
+
+ # Fill the template with the information
+ self.tex = render_to_string("treasury/invoice_sample.tex", dict(obj=self, products=products))
+
+ return super().save(*args, **kwargs)
+
class Meta:
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
@@ -74,7 +117,9 @@ class Product(models.Model):
invoice = models.ForeignKey(
Invoice,
- on_delete=models.PROTECT,
+ on_delete=models.CASCADE,
+ related_name="products",
+ verbose_name=_("invoice"),
)
designation = models.CharField(
@@ -92,7 +137,7 @@ class Product(models.Model):
@property
def amount_euros(self):
- return self.amount / 100
+ return "{:.2f}".format(self.amount / 100)
@property
def total(self):
@@ -100,7 +145,7 @@ class Product(models.Model):
@property
def total_euros(self):
- return self.total / 100
+ return "{:.2f}".format(self.total / 100)
class Meta:
verbose_name = _("product")
@@ -276,13 +321,14 @@ class SogeCredit(models.Model):
last_name=self.user.last_name,
first_name=self.user.first_name,
bank="Société générale",
+ created_at=self.transactions.order_by("-created_at").first().created_at,
)
self.save()
for transaction in self.transactions.all():
transaction.valid = True
transaction._force_save = True
- transaction.created_at = datetime.now()
+ transaction.created_at = timezone.now()
transaction.save()
def delete(self, **kwargs):
@@ -301,7 +347,7 @@ class SogeCredit(models.Model):
for transaction in self.transactions.all():
transaction._force_save = True
transaction.valid = True
- transaction.created_at = datetime.now()
+ transaction.created_at = timezone.now()
transaction.save()
super().delete(**kwargs)
diff --git a/apps/treasury/tables.py b/apps/treasury/tables.py
index 9f4e43e6..14044f1c 100644
--- a/apps/treasury/tables.py
+++ b/apps/treasury/tables.py
@@ -14,19 +14,39 @@ class InvoiceTable(tables.Table):
"""
List all invoices.
"""
- id = tables.LinkColumn("treasury:invoice_update",
- args=[A("pk")],
- text=lambda record: _("Invoice #{:d}").format(record.id), )
+ id = tables.LinkColumn(
+ "treasury:invoice_update",
+ args=[A("pk")],
+ text=lambda record: _("Invoice #{:d}").format(record.id),
+ )
- invoice = tables.LinkColumn("treasury:invoice_render",
- verbose_name=_("Invoice"),
- args=[A("pk")],
- accessor="pk",
- text="",
- attrs={
- 'a': {'class': 'fa fa-file-pdf-o'},
- 'td': {'data-turbolinks': 'false'}
- })
+ invoice = tables.LinkColumn(
+ "treasury:invoice_render",
+ verbose_name=_("Invoice"),
+ args=[A("pk")],
+ accessor="pk",
+ text="",
+ attrs={
+ 'a': {'class': 'fa fa-file-pdf-o'},
+ 'td': {'data-turbolinks': 'false'}
+ }
+ )
+
+ delete = tables.LinkColumn(
+ 'treasury:invoice_delete',
+ args=[A('pk')],
+ verbose_name=_("delete"),
+ text=_("Delete"),
+ attrs={
+ 'th': {
+ 'id': 'delete-membership-header'
+ },
+ 'a': {
+ 'class': 'btn btn-danger',
+ 'data-type': 'delete-membership'
+ }
+ },
+ )
class Meta:
attrs = {
@@ -64,6 +84,7 @@ class RemittanceTable(tables.Table):
model = Remittance
template_name = 'django_tables2/bootstrap4.html'
fields = ('id', 'date', 'remittance_type', 'comment', 'count', 'amount', 'view',)
+ order_by = ('-date',)
class SpecialTransactionTable(tables.Table):
@@ -74,7 +95,7 @@ class SpecialTransactionTable(tables.Table):
# Display add and remove buttons. Use the `exclude` field to select what is needed.
remittance_add = tables.LinkColumn("treasury:link_transaction",
verbose_name=_("Remittance"),
- args=[A("specialtransactionproxy.pk")],
+ args=[A("specialtransactionproxy__pk")],
text=_("Add"),
attrs={
'a': {'class': 'btn btn-primary'}
@@ -82,7 +103,7 @@ class SpecialTransactionTable(tables.Table):
remittance_remove = tables.LinkColumn("treasury:unlink_transaction",
verbose_name=_("Remittance"),
- args=[A("specialtransactionproxy.pk")],
+ args=[A("specialtransactionproxy__pk")],
text=_("Remove"),
attrs={
'a': {'class': 'btn btn-primary btn-danger'}
@@ -100,7 +121,8 @@ class SpecialTransactionTable(tables.Table):
}
model = SpecialTransaction
template_name = 'django_tables2/bootstrap4.html'
- fields = ('id', 'source', 'destination', 'last_name', 'first_name', 'bank', 'amount', 'reason',)
+ fields = ('created_at', 'source', 'destination', 'last_name', 'first_name', 'bank', 'amount', 'reason',)
+ order_by = ('-created_at',)
class SogeCreditTable(tables.Table):
diff --git a/apps/treasury/templates/treasury/invoice_confirm_delete.html b/apps/treasury/templates/treasury/invoice_confirm_delete.html
new file mode 100644
index 00000000..e1de9c83
--- /dev/null
+++ b/apps/treasury/templates/treasury/invoice_confirm_delete.html
@@ -0,0 +1,35 @@
+{% extends "base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load i18n crispy_forms_tags %}
+
+{% block content %}
+
+
+
{% trans "Delete invoice" %}
+
+ {% if object.locked %}
+
+
+ {% blocktrans %}This invoice is locked and can't be deleted.{% endblocktrans %}
+
+
+ {% else %}
+
+
+ {% blocktrans %}Are you sure you want to delete this invoice? This action can't be undone.{% endblocktrans %}
+
+ {% blocktrans trimmed %}
+ Warning: the LaTeX template is saved with this object. Updating the invoice implies regenerate it.
+ Be careful if you manipulate old invoices.
+ {% endblocktrans %}
+
+ {% elif object.locked %}
+
+ {% blocktrans trimmed %}
+ This invoice is locked and can no longer be edited.
+ {% endblocktrans %}
+
+ {% endif %}
+
+
+
+
+
+{# Hidden div that store an empty product form, to be copied into new forms #}
+
+
+ {% if "note.view_note_balance"|has_perm:object.user.note %}
+
{% trans 'balance'|capfirst %}
+
{{ object.user.note.balance|pretty_money }}
+ {% endif %}
+
+
{% trans 'transactions'|capfirst %}
+
+ {% for transaction in object.transactions.all %}
+ {{ transaction.membership.club }} ({{ transaction.amount|pretty_money }})
+ {% endfor %}
+
+
+
{% trans 'total amount'|capfirst %}
+
{{ object.amount|pretty_money }}
+
+
+
+
+ {% trans 'Warning: Validating this credit implies that all membership transactions will be validated.' %}
+ {% trans 'If you delete this credit, there all membership transactions will be also validated, but no credit will be operated.' %}
+ {% trans "If this credit is validated, then the user won't be able to ask for a credit from the Société générale." %}
+ {% trans 'If you think there is an error, please contact the "respos info".' %}
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/apps/treasury/templates/treasury/sogecredit_list.html b/apps/treasury/templates/treasury/sogecredit_list.html
new file mode 100644
index 00000000..c3862811
--- /dev/null
+++ b/apps/treasury/templates/treasury/sogecredit_list.html
@@ -0,0 +1,77 @@
+{% extends "base.html" %}
+{% comment %}
+SPDX-License-Identifier: GPL-3.0-or-later
+{% endcomment %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block content %}
+
+ Le WEI (Week-End d’Intégration), ou 3 jours d’immersion dans les profondeurs du
+ monde post-préparatoire.
+
+
+ Que serait une école sans son week-end d’intégration ? Quelques semaines après la
+ rentrée, on embarque tous et toutes à bord de bus à thèmes pour quelques jours
+ inoubliables dans une destination inconnue. L’objectif de ce week-end : permettre aux
+ nouvel·les arrivant·es de se lâcher après 2 ans de dur labeur (voire 3 pour les plus
+ chanceux), de découvrir l’ambiance familiale de l’ENS ainsi que de nouer des liens avec
+ ceux·elles qu’ils côtoieront par la suite. Dose de chants et de fun garantie !
+