diff --git a/docs/conf.py b/docs/conf.py index 5b21ed137..01cf10ada 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,8 +48,8 @@ master_doc = "index" # General information about the project. -project = u"LEGO" -copyright = u"2014 - 2020, Abakus Webkom" +project = "LEGO" +copyright = "2014 - 2020, Abakus Webkom" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -197,7 +197,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ("index", "LEGO.tex", u"LEGO Documentation", u"Abakus Webkom", "manual") + ("index", "LEGO.tex", "LEGO Documentation", "Abakus Webkom", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -224,7 +224,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [("index", "lego", u"LEGO Documentation", [u"Abakus Webkom"], 1)] +man_pages = [("index", "lego", "LEGO Documentation", ["Abakus Webkom"], 1)] # If true, show URL addresses after external links. # man_show_urls = False @@ -238,8 +238,8 @@ ( "index", "LEGO", - u"LEGO Documentation", - u"Abakus Webkom", + "LEGO Documentation", + "Abakus Webkom", "LEGO", "One line description of project.", "Miscellaneous", diff --git a/lego/api/v1.py b/lego/api/v1.py index 37b606863..e15d48810 100644 --- a/lego/api/v1.py +++ b/lego/api/v1.py @@ -40,6 +40,8 @@ from lego.apps.gallery.views import GalleryPictureViewSet, GalleryViewSet from lego.apps.ical.viewsets import ICalTokenViewset, ICalViewset from lego.apps.joblistings.views import JoblistingViewSet +from lego.apps.lending.urls import urlpatterns as lending_urls +from lego.apps.lending.views import LendableObjectViewSet, LendingInstanceViewSet from lego.apps.meetings.views import ( MeetingInvitationTokenViewSet, MeetingInvitationViewSet, @@ -145,6 +147,9 @@ basename="abakusgroup-memberships", ) router.register(r"joblistings", JoblistingViewSet, basename="joblisting") +router.register(r"lendableobject", LendableObjectViewSet, basename="lendableobject") +router.register(r"lendinginstance", LendingInstanceViewSet, basename="lendinginstance") + router.register( r"meeting-token", MeetingInvitationTokenViewSet, basename="meeting-token" ) @@ -210,4 +215,5 @@ urlpatterns = [ path("", include(router.urls)), path("forums/", include((forums_urls, "forums"))), + path("", include((lending_urls, "lending"))), ] diff --git a/lego/apps/comments/models.py b/lego/apps/comments/models.py index e2c04de53..321bdf2f6 100644 --- a/lego/apps/comments/models.py +++ b/lego/apps/comments/models.py @@ -1,9 +1,10 @@ -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models from lego.apps.comments.permissions import CommentPermissionHandler from lego.apps.content.fields import ContentField +from lego.apps.reactions.models import Reaction from lego.utils.managers import BasisModelManager from lego.utils.models import BasisModel @@ -19,6 +20,7 @@ class Comment(BasisModel): object_id = models.PositiveIntegerField(db_index=True) content_object = GenericForeignKey() parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE) + reactions = GenericRelation(Reaction) objects = CommentManager() # type: ignore @@ -34,5 +36,29 @@ def delete(self): else: super(Comment, self).delete(force=True) + def get_reactions_grouped(self, user): + grouped = {} + for reaction in self.reactions.all(): + if reaction.emoji.pk not in grouped: + grouped[reaction.emoji.pk] = { + "emoji": reaction.emoji.pk, + "unicode_string": reaction.emoji.unicode_string, + "count": 0, + "has_reacted": False, + "reaction_id": None, + } + + grouped[reaction.emoji.pk]["count"] += 1 + + if reaction.created_by == user: + grouped[reaction.emoji.pk]["has_reacted"] = True + grouped[reaction.emoji.pk]["reaction_id"] = reaction.id + + return sorted(grouped.values(), key=lambda kv: kv["count"], reverse=True) + def __str__(self): return self.text + + @property + def content_target_self(self): + return f"{self._meta.app_label}.{self._meta.model_name}-{self.pk}" diff --git a/lego/apps/comments/serializers.py b/lego/apps/comments/serializers.py index 5eee62b6f..81f7d21ff 100644 --- a/lego/apps/comments/serializers.py +++ b/lego/apps/comments/serializers.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers from rest_framework.exceptions import ValidationError -from rest_framework.fields import DateTimeField +from rest_framework.fields import CharField, DateTimeField from lego.apps.comments.models import Comment from lego.apps.content.fields import ContentSerializerField @@ -14,6 +15,8 @@ class CommentSerializer(BasisModelSerializer): updated_at = DateTimeField(read_only=True) content_target = GenericRelationField(source="content_object") text = ContentSerializerField() + reactions_grouped = serializers.SerializerMethodField() + content_target_self = CharField(read_only=True) class Meta: model = Comment @@ -22,11 +25,17 @@ class Meta: "text", "author", "content_target", + "content_target_self", "created_at", "updated_at", "parent", + "reactions_grouped", ) + def get_reactions_grouped(self, obj): + user = self.context["request"].user + return obj.get_reactions_grouped(user) + def validate(self, attrs): content_target = attrs.get("content_object") diff --git a/lego/apps/comments/tests/test_reactions.py b/lego/apps/comments/tests/test_reactions.py new file mode 100644 index 000000000..6db2824b8 --- /dev/null +++ b/lego/apps/comments/tests/test_reactions.py @@ -0,0 +1,56 @@ +from lego.apps.articles.models import Article +from lego.apps.comments.models import Comment +from lego.apps.emojis.models import Emoji +from lego.apps.reactions.models import Reaction +from lego.apps.users.models import User +from lego.utils.test_utils import BaseTestCase + + +class CommentReactionsTestCase(BaseTestCase): + fixtures = [ + "test_abakus_groups.yaml", + "test_users.yaml", + "test_emojis.yaml", + "test_articles.yaml", + ] + + def setUp(self): + self.comment = Comment.objects.create( + created_by_id=0, + text="first comment", + content_object=Article.objects.all().first(), + ) + self.user = User.objects.all().first() + self.comment.created_by = self.user + self.comment.save() + + self.emoji = Emoji.objects.first() + + def test_add_reaction(self): + test_reaction = Reaction.objects.create( + content_object=self.comment, emoji=self.emoji + ) + test_reaction.created_by = self.user + test_reaction.save() + + reactions_grouped = self.comment.get_reactions_grouped(self.user) + self.assertEqual(len(reactions_grouped), 1) + self.assertEqual(reactions_grouped[0]["count"], 1) + self.assertEqual(reactions_grouped[0]["has_reacted"], True) + self.assertEqual( + reactions_grouped[0]["unicode_string"], self.emoji.unicode_string + ) + + def test_remove_reaction(self): + test_reaction = Reaction.objects.create( + content_object=self.comment, emoji=self.emoji + ) + + reactions_grouped = self.comment.get_reactions_grouped(self.user) + self.assertEqual(len(reactions_grouped), 1) + self.assertEqual(reactions_grouped[0]["count"], 1) + + test_reaction.delete() + + reactions_grouped = self.comment.get_reactions_grouped(self.user) + self.assertEqual(len(reactions_grouped), 0) diff --git a/lego/apps/email/filters.py b/lego/apps/email/filters.py index 7666a142b..bd77b98b8 100644 --- a/lego/apps/email/filters.py +++ b/lego/apps/email/filters.py @@ -3,7 +3,14 @@ from django_filters.rest_framework import BooleanFilter, CharFilter, FilterSet from lego.apps.email.models import EmailList -from lego.apps.users.constants import GROUP_COMMITTEE, GROUP_GRADE +from lego.apps.users.constants import ( + GROUP_BOARD, + GROUP_COMMITTEE, + GROUP_GRADE, + GROUP_ORDAINED, + GROUP_SUB, +) +from lego.apps.users.filters import CharInFilter from lego.apps.users.models import User @@ -21,8 +28,8 @@ class EmailUserFilterSet(FilterSet): email = CharFilter(field_name="internal_email__email", lookup_expr="icontains") userUsername = CharFilter(field_name="username", lookup_expr="icontains") userFullname = CharFilter(field_name="userFullname", method="fullname") - userCommittee = CharFilter(field_name="userCommitee", method="commitee") userGrade = CharFilter(field_name="abakus_groups", method="grade") + userGroups = CharInFilter(field_name="userGroups", method="groups") enabled = BooleanFilter(field_name="internal_email_enabled") def grade(self, queryset, name, value): @@ -34,13 +41,15 @@ def grade(self, queryset, name, value): ) return queryset - def commitee(self, queryset, name, value): - if value == "-": - return queryset.exclude(abakus_groups__type=GROUP_COMMITTEE) + def groups(self, queryset, name, value): + relevant_types = [GROUP_COMMITTEE, GROUP_BOARD, GROUP_ORDAINED, GROUP_SUB] + + if value == ["-"]: + return queryset.exclude(abakus_groups__type__in=relevant_types) if value: return queryset.filter( - abakus_groups__name__icontains=value, - abakus_groups__type=GROUP_COMMITTEE, + abakus_groups__name__in=value, + abakus_groups__type__in=relevant_types, ) return queryset @@ -59,5 +68,5 @@ class Meta: "userUsername", "userFullname", "userGrade", - "userCommittee", + "userGroups", ) diff --git a/lego/apps/email/serializers.py b/lego/apps/email/serializers.py index 97ef9df2f..e5209b771 100644 --- a/lego/apps/email/serializers.py +++ b/lego/apps/email/serializers.py @@ -1,3 +1,5 @@ +from typing import Any + from django.core.exceptions import ValidationError from rest_framework import exceptions, serializers @@ -7,7 +9,7 @@ PublicUserListField, PublicUserWithGroupsField, ) -from lego.apps.users.models import User +from lego.apps.users.models import Membership, User from .fields import EmailAddressField @@ -44,6 +46,33 @@ class Meta: "additional_emails", ) + def validate(self, attrs: Any) -> Any: + # Use existing values where missing to support patch requests + get = ( + lambda index: attrs[index] + if index in attrs + else getattr(self.instance, index) + ) + length = ( + lambda value: len(value) if hasattr(value, "__len__") else value.count() + ) + # Require at least one receiver of the email list + users_len, emails_len = length(get("users")), length(get("additional_emails")) + if users_len == 0 and emails_len == 0: + groups = ( + attrs["groups"] if "groups" in attrs else self.instance.groups.all() + ) + group_roles = get("group_roles") + memberships = Membership.objects.filter(abakus_group__in=groups) + + if length(group_roles) > 0: + memberships = memberships.filter(role__in=group_roles) + + if not memberships.exists(): + raise ValidationError("Cannot create a mail list without receivers") + + return super().validate(attrs) + class EmailListDetailSerializer(EmailListSerializer): users = PublicUserListField({"read_only": True}) diff --git a/lego/apps/email/tests/test_views.py b/lego/apps/email/tests/test_views.py index 61562740d..c8058b0d2 100644 --- a/lego/apps/email/tests/test_views.py +++ b/lego/apps/email/tests/test_views.py @@ -1,7 +1,7 @@ from rest_framework import status -from lego.apps.email.models import EmailList -from lego.apps.users.models import AbakusGroup, User +from lego.apps.email.models import EmailAddress, EmailList +from lego.apps.users.models import AbakusGroup, Membership, User from lego.utils.test_utils import BaseAPITestCase @@ -116,6 +116,21 @@ def test_create_list_invalid_additional_email(self): ) self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + def test_create_list_no_recipients(self): + """Bad request when user tries to create list with no recipients""" + response = self.client.post( + self.url, + { + "name": "Webkom", + "email": "webbers", + "users": [], + "groups": [self.admin_group.id], + "group_roles": ["recruiting"], + "additional_emails": [], + }, + ) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + def test_edit_additional_email(self): """Test ability to remove an additional email""" response = self.client.patch( @@ -138,7 +153,9 @@ def test_edit_additional_email(self): def test_delete_additional_email(self): """Test ability to set additional emails to an empty array""" - response = self.client.patch(f"{self.url}1/", {"additional_emails": []}) + response = self.client.patch( + f"{self.url}1/", {"additional_emails": [], "users": [1]} + ) self.assertEqual(status.HTTP_200_OK, response.status_code) self.assertEqual([], EmailList.objects.get(id=1).additional_emails) @@ -149,8 +166,31 @@ def test_change_assigned_email(self): self.assertEqual("address", EmailList.objects.get(id=1).email_id) + def test_edit_list_no_recipients(self): + """Bad request when user tries to edit list with no recipients""" + response = self.client.patch( + f"{self.url}1/", + { + "users": [], + "groups": [self.admin_group.id], + "group_roles": ["recruiting"], + "additional_emails": [], + }, + ) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + + def test_edit_list_no_recipients_partial(self): + """Bad request when user tries to edit list with no recipients""" + response = self.client.patch( + f"{self.url}1/", + { + "additional_emails": [], + }, + ) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + def test_delete_endpoint_not_available(self): - """The delete endpoint is'nt available.""" + """The delete endpoint isn't available.""" response = self.client.delete(f"{self.url}1/") self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) @@ -178,6 +218,36 @@ def test_list(self): response = self.client.get(self.url) self.assertEqual(status.HTTP_200_OK, response.status_code) + def test_list_filter_groups(self): + """The list endpoint can be filtered by group memberships""" + webkom = AbakusGroup.objects.get(name="Webkom") + webber = User.objects.create( + username="Webber", + email="webber", + internal_email=EmailAddress.objects.create(email="webber"), + ) + Membership.objects.create(abakus_group=webkom, user=webber) + User.objects.create( + username="Pleb", + email="pleb", + internal_email=EmailAddress.objects.create(email="pleb"), + ) + response = self.client.get(self.url) + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(3, len(response.json()["results"])) + + response = self.client.get(f"{self.url}?userGroups=Webkom") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(1, len(response.json()["results"])) + + response = self.client.get(f"{self.url}?userGroups=Webkom,EmailAdminTest") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(1, len(response.json()["results"])) + + response = self.client.get(f"{self.url}?userGroups=-") + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(2, len(response.json()["results"])) + def test_retrieve(self): """It is possible to retrieve the user""" response = self.client.get(f"{self.url}1/") diff --git a/lego/apps/events/constants.py b/lego/apps/events/constants.py index 2753b7b83..1f5281116 100644 --- a/lego/apps/events/constants.py +++ b/lego/apps/events/constants.py @@ -70,6 +70,7 @@ class PRESENCE_CHOICES(models.TextChoices): UNKNOWN = "UNKNOWN" PRESENT = "PRESENT" + LATE = "LATE" NOT_PRESENT = "NOT_PRESENT" diff --git a/lego/apps/events/migrations/0040_alter_registration_presence.py b/lego/apps/events/migrations/0040_alter_registration_presence.py new file mode 100644 index 000000000..5c960e230 --- /dev/null +++ b/lego/apps/events/migrations/0040_alter_registration_presence.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.10 on 2024-03-06 22:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0039_remove_event_use_contact_tracing"), + ] + + operations = [ + migrations.AlterField( + model_name="registration", + name="presence", + field=models.CharField( + choices=[ + ("UNKNOWN", "Unknown"), + ("PRESENT", "Present"), + ("LATE", "Late"), + ("NOT_PRESENT", "Not Present"), + ], + default="UNKNOWN", + max_length=20, + ), + ), + ] diff --git a/lego/apps/events/models.py b/lego/apps/events/models.py index 9400fa54b..20699d892 100644 --- a/lego/apps/events/models.py +++ b/lego/apps/events/models.py @@ -30,7 +30,7 @@ from lego.apps.files.models import FileField from lego.apps.followers.models import FollowEvent from lego.apps.permissions.models import ObjectPermissionsModel -from lego.apps.users.constants import AUTUMN, SPRING +from lego.apps.users.constants import AUTUMN, PENALTY_TYPES, PENALTY_WEIGHTS, SPRING from lego.apps.users.models import AbakusGroup, Membership, Penalty, User from lego.utils.models import BasisModel from lego.utils.youtube_validator import youtube_validator @@ -934,26 +934,47 @@ def set_presence(self, presence: constants.PRESENCE_CHOICES) -> None: """Wrap this method in a transaction""" if presence not in constants.PRESENCE_CHOICES: raise ValueError("Illegal presence choice") + self.presence = presence self.handle_user_penalty(presence) self.save() + def delete_presence_penalties_for_event(self) -> None: + for penalty in self.user.penalties.filter( + source_event=self.event, type=PENALTY_TYPES.PRESENCE + ): + penalty.delete() + def handle_user_penalty(self, presence: constants.PRESENCE_CHOICES) -> None: + """ + Previous penalties related to the event are deleted since the + newest presence is the only one that matters + """ + if ( self.event.heed_penalties and presence == constants.PRESENCE_CHOICES.NOT_PRESENT and self.event.penalty_weight_on_not_present ): - if not self.user.penalties.filter(source_event=self.event).exists(): - Penalty.objects.create( - user=self.user, - reason=f"Møtte ikke opp på {self.event.title}.", - weight=self.event.penalty_weight_on_not_present, - source_event=self.event, - ) + self.delete_presence_penalties_for_event() + Penalty.objects.create( + user=self.user, + reason=f"Møtte ikke opp på {self.event.title}.", + weight=self.event.penalty_weight_on_not_present, + source_event=self.event, + type=PENALTY_TYPES.PRESENCE, + ) + elif self.event.heed_penalties and presence == constants.PRESENCE_CHOICES.LATE: + self.delete_presence_penalties_for_event() + Penalty.objects.create( + user=self.user, + reason=f"Møtte for sent opp på {self.event.title}.", + weight=PENALTY_WEIGHTS.LATE_PRESENCE, + source_event=self.event, + type=PENALTY_TYPES.PRESENCE, + ) else: - for penalty in self.user.penalties.filter(source_event=self.event): - penalty.delete() + self.delete_presence_penalties_for_event() def add_to_pool(self, pool: Pool) -> Registration: allowed: bool = False diff --git a/lego/apps/events/serializers/registrations.py b/lego/apps/events/serializers/registrations.py index a3ed6dde4..3ce2220c3 100644 --- a/lego/apps/events/serializers/registrations.py +++ b/lego/apps/events/serializers/registrations.py @@ -1,4 +1,3 @@ -from django.db import transaction from rest_framework import serializers from rest_framework_jwt.serializers import ImpersonateAuthTokenSerializer @@ -65,13 +64,12 @@ class Meta: ) def update(self, instance, validated_data): - with transaction.atomic(): - presence = validated_data.pop("presence", None) - super().update(instance, validated_data) - if presence: - instance.set_presence(presence) + presence = validated_data.pop("presence", None) + super().update(instance, validated_data) + if presence: + instance.set_presence(presence) - return instance + return instance class RegistrationAnonymizedReadSerializer(BasisModelSerializer): diff --git a/lego/apps/events/tests/test_penalties.py b/lego/apps/events/tests/test_penalties.py index f518ea092..dc7d998a3 100644 --- a/lego/apps/events/tests/test_penalties.py +++ b/lego/apps/events/tests/test_penalties.py @@ -4,6 +4,7 @@ from lego.apps.events import constants from lego.apps.events.models import Event, Registration +from lego.apps.users.constants import LATE_PRESENCE_PENALTY_WEIGHT from lego.apps.users.models import AbakusGroup, Penalty from lego.utils.test_utils import BaseTestCase @@ -398,6 +399,19 @@ def test_penalties_created_when_not_present(self): self.assertEqual(penalties_before, 0) self.assertEqual(penalties_after, event.penalty_weight_on_not_present) + def test_penalties_created_when_late_present(self): + """Test that user gets penalties when late present""" + event = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") + + registration = event.registrations.first() + penalties_before = registration.user.number_of_penalties() + + registration.set_presence(constants.PRESENCE_CHOICES.LATE) + + penalties_after = registration.user.number_of_penalties() + self.assertEqual(penalties_before, 0) + self.assertEqual(penalties_after, LATE_PRESENCE_PENALTY_WEIGHT) + def test_penalties_removed_when_not_present_changes(self): """Test that penalties for not_present gets removed when resetting presence""" event = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") @@ -411,8 +425,23 @@ def test_penalties_removed_when_not_present_changes(self): self.assertEqual(penalties_before, event.penalty_weight_on_not_present) self.assertEqual(penalties_after, 0) + def test_penalties_removed_when_late_present_changes(self): + """Test that penalties for late presence gets removed when changing to present""" + event = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") + registration = event.registrations.first() + registration.set_presence(constants.PRESENCE_CHOICES.LATE) + + penalties_before = registration.user.number_of_penalties() + registration.set_presence(constants.PRESENCE_CHOICES.PRESENT) + + penalties_after = registration.user.number_of_penalties() + self.assertEqual(penalties_before, LATE_PRESENCE_PENALTY_WEIGHT) + self.assertEqual(penalties_after, 0) + def test_only_correct_penalties_are_removed_on_presence_change(self): - """Test that only penalties for given event are removed when changing presence""" + """ + Test that only penalties of type presence for given event are removed when changing presence + """ event = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") other_event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") registration = event.registrations.first() @@ -447,6 +476,41 @@ def test_only_correct_penalties_are_removed_on_presence_change(self): ) self.assertEqual(penalties_after, other_event.penalty_weight_on_not_present) + def test_only_correct_penalties_are_removed_on_presence_change_on_same_event(self): + """Test that only penalties of type presence are removed when changing presence""" + event = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") + registration = event.registrations.first() + + registration.set_presence(constants.PRESENCE_CHOICES.NOT_PRESENT) + penalties_before = registration.user.number_of_penalties() + penalties_object_before = list(registration.user.penalties.all()) + + # Default penalty type is other + Penalty.objects.create( + user=registration.user, + reason="SAME EVENT", + weight=2, + source_event=event, + ) + penalties_during = registration.user.number_of_penalties() + penalties_objects_during = list(registration.user.penalties.all()) + + registration.set_presence(constants.PRESENCE_CHOICES.UNKNOWN) + penalties_after = registration.user.number_of_penalties() + penalties_object_after = list(registration.user.penalties.all()) + + self.assertEqual(penalties_object_before[0].source_event, event) + self.assertEqual(penalties_object_after[0].source_event, event) + self.assertEqual(len(penalties_object_before), 1) + self.assertEqual(len(penalties_objects_during), 2) + self.assertEqual(len(penalties_object_after), 1) + self.assertEqual(penalties_before, event.penalty_weight_on_not_present) + self.assertEqual( + penalties_during, + event.penalty_weight_on_not_present + event.penalty_weight_on_not_present, + ) + self.assertEqual(penalties_after, event.penalty_weight_on_not_present) + def test_able_to_register_when_not_heed_penalties_with_penalties(self): """Test that user is able to register when heed_penalties is false and user has penalties""" event = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") diff --git a/lego/apps/joblistings/tests/test_joblistings_api.py b/lego/apps/joblistings/tests/test_joblistings_api.py index 3844373a6..968ebbb86 100644 --- a/lego/apps/joblistings/tests/test_joblistings_api.py +++ b/lego/apps/joblistings/tests/test_joblistings_api.py @@ -98,12 +98,12 @@ def setUp(self): def test_with_abakus_user(self): AbakusGroup.objects.get(name="Abakus").add_user(self.abakus_user) self.client.force_authenticate(self.abakus_user) - joblisting_response = self.client.get(_get_list_url()) + joblisting_response = self.client.get(_get_list_url(), {"timeFilter": True}) self.assertEqual(joblisting_response.status_code, status.HTTP_200_OK) self.assertEqual(len(joblisting_response.json()["results"]), 4) def test_without_user(self): - joblisting_response = self.client.get(_get_list_url()) + joblisting_response = self.client.get(_get_list_url(), {"timeFilter": True}) self.assertEqual(joblisting_response.status_code, status.HTTP_200_OK) self.assertEqual(len(joblisting_response.json()["results"]), 4) @@ -111,7 +111,7 @@ def test_list_after_visible_to(self): joblisting = Joblisting.objects.all().first() joblisting.visible_to = timezone.now() - timedelta(days=2) joblisting.save() - joblisting_response = self.client.get(_get_list_url()) + joblisting_response = self.client.get(_get_list_url(), {"timeFilter": True}) self.assertEqual(joblisting_response.status_code, status.HTTP_200_OK) self.assertEqual(len(joblisting_response.json()["results"]), 3) @@ -119,9 +119,23 @@ def test_before_visible(self): joblisting = Joblisting.objects.all().first() joblisting.visible_from = timezone.now() + timedelta(days=2) joblisting.save() - joblisting_response = self.client.get(_get_detail_url(1)) + joblisting_response = self.client.get(_get_detail_url(1), {"timeFilter": True}) self.assertEqual(joblisting_response.status_code, status.HTTP_404_NOT_FOUND) + def test_with_company_query_param(self): + company_pk = 1 + joblisting_response = self.client.get(_get_list_url(), {"company": company_pk}) + self.assertEqual(joblisting_response.status_code, status.HTTP_200_OK) + self.assertEqual(len(joblisting_response.json()["results"]), 1) + self.assertEqual( + joblisting_response.json()["results"][0]["company"]["id"], company_pk + ) + + def test_without_time_filter(self): + joblisting_response = self.client.get(_get_list_url()) + self.assertEqual(joblisting_response.status_code, status.HTTP_200_OK) + self.assertEqual(len(joblisting_response.json()["results"]), 5) + class RetrieveJoblistingsTestCase(BaseAPITestCase): fixtures = [ diff --git a/lego/apps/joblistings/views.py b/lego/apps/joblistings/views.py index 3aa041626..94c731834 100644 --- a/lego/apps/joblistings/views.py +++ b/lego/apps/joblistings/views.py @@ -31,8 +31,10 @@ def get_object(self) -> Joblisting: self.check_object_permissions(self.request, obj) except PermissionError: raise Http404 from None + if obj.visible_from > timezone.now() and self.request.user != obj.created_by: raise Http404 from None + return obj def get_serializer_class(self): @@ -45,8 +47,12 @@ def get_serializer_class(self): return JoblistingSerializer def get_queryset(self): - if self.action == "list": - return Joblisting.objects.filter( + queryset = Joblisting.objects.all() + + time_filter = self.request.query_params.get("timeFilter", False) + if time_filter: + queryset = queryset.filter( visible_from__lte=timezone.now(), visible_to__gte=timezone.now() ) - return Joblisting.objects.all() + + return queryset diff --git a/lego/apps/lending/__init__.py b/lego/apps/lending/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lego/apps/lending/apps.py b/lego/apps/lending/apps.py new file mode 100644 index 000000000..608fc9e03 --- /dev/null +++ b/lego/apps/lending/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LendingConfig(AppConfig): + name = "lego.apps.lending" + verbose_name = "Lending" diff --git a/lego/apps/lending/filters.py b/lego/apps/lending/filters.py new file mode 100644 index 000000000..215d56564 --- /dev/null +++ b/lego/apps/lending/filters.py @@ -0,0 +1,19 @@ +from django_filters.rest_framework import FilterSet + +from lego.apps.lending.models import LendableObject, LendingInstance + + +class LendableObjectFilterSet(FilterSet): + class Meta: + model = LendableObject + fields = ["title"] + + +class LendingInstanceFilterSet(FilterSet): + class Meta: + model = LendingInstance + fields = [ + "lendable_object", + "start_date", + "status", + ] diff --git a/lego/apps/lending/fixtures/development_lendable_objects.yaml b/lego/apps/lending/fixtures/development_lendable_objects.yaml new file mode 100644 index 000000000..cbf72d649 --- /dev/null +++ b/lego/apps/lending/fixtures/development_lendable_objects.yaml @@ -0,0 +1,30 @@ +- fields: + title: "Grill" + description: "En grill til å grille" + location: "A3" + image: test_event_cover.png + has_contract: False + max_lending_period: "2 days" + responsible_groups: + - - Webkom + # responsible_roles: + # - member + created_by: 3 + model: lending.LendableObject + pk: 1 +- fields: + title: "Soundboks" + description: "En soundboks til å sondbokse" + location: "It lageret" + # image: "test_event_cover.png" + created_by: 3 + model: lending.LendableObject + pk: 2 +- fields: + title: "Prinsessekjole" + description: "" + location: "" + # image: "test_event_covet.png" + created_by: 3 + model: lending.LendableObject + pk: 3 \ No newline at end of file diff --git a/lego/apps/lending/fixtures/development_lending_requests.yaml b/lego/apps/lending/fixtures/development_lending_requests.yaml new file mode 100644 index 000000000..7063cc881 --- /dev/null +++ b/lego/apps/lending/fixtures/development_lending_requests.yaml @@ -0,0 +1,17 @@ +- fields: + lendable_object: 1 + created_by: 3 + start_date: '2024-02-02T23:16:00+00:00' + end_date: '2024-02-02T23:18:00+00:00' + message: "Jeg vil låne grill :)" + status: "PENDING" + model: lending.LendingInstance + pk: 1 +- fields: + lendable_object: 2 + created_by: 3 + start_date: '2024-02-02T23:16:00+00:00' + end_date: '2024-02-02T23:18:00+00:00' + status: "PENDING" + model: lending.LendingInstance + pk: 2 diff --git a/lego/apps/lending/managers.py b/lego/apps/lending/managers.py new file mode 100644 index 000000000..179357324 --- /dev/null +++ b/lego/apps/lending/managers.py @@ -0,0 +1,31 @@ +from lego.utils.managers import BasisModelManager + + +class LendingInstanceManager(BasisModelManager): + def create(self, *args, **kwargs): + from lego.apps.lending.notifications import LendingInstanceCreateNotification + from lego.apps.users.models import Membership, User + + lending_instance = super().create(*args, **kwargs) + abakus_groups = lending_instance.lendable_object.responsible_groups.all() + roles = lending_instance.lendable_object.responsible_roles + if roles: + users_to_be_notified = Membership.objects.filter( + abakus_group__in=abakus_groups, role__in=roles + ).values_list("user", flat=True) + else: + users_to_be_notified = Membership.objects.filter( + abakus_group__in=abakus_groups + ).values_list("user", flat=True) + for user_id in users_to_be_notified: + user = User.objects.get(pk=user_id) + notification = LendingInstanceCreateNotification( + lending_instance=lending_instance, + user=user, + ) + notification.notify() + + return lending_instance + + def get_queryset(self): + return super().get_queryset().select_related("created_by") diff --git a/lego/apps/lending/migrations/0001_initial.py b/lego/apps/lending/migrations/0001_initial.py new file mode 100644 index 000000000..26c675ded --- /dev/null +++ b/lego/apps/lending/migrations/0001_initial.py @@ -0,0 +1,260 @@ +# Generated by Django 4.0.10 on 2024-05-24 11:03 + +import datetime + +import django.contrib.postgres.fields +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + +import lego.apps.files.models +import lego.apps.lending.validators + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("users", "0043_alter_abakusgroup_type"), + ("files", "0005_file_save_for_use"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="LendableObject", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + db_index=True, default=django.utils.timezone.now, editable=False + ), + ), + ( + "updated_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ( + "deleted", + models.BooleanField(db_index=True, default=False, editable=False), + ), + ("require_auth", models.BooleanField(default=True)), + ("title", models.CharField(max_length=128)), + ("description", models.TextField(blank=True)), + ("has_contract", models.BooleanField(default=False)), + ( + "max_lending_period", + models.DurationField(default=datetime.timedelta(days=7), null=True), + ), + ( + "responsible_roles", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("member", "member"), + ("leader", "leader"), + ("co-leader", "co-leader"), + ("treasurer", "treasurer"), + ("recruiting", "recruiting"), + ("development", "development"), + ("editor", "editor"), + ("retiree", "retiree"), + ("media_relations", "media_relations"), + ("active_retiree", "active_retiree"), + ("alumni", "alumni"), + ("webmaster", "webmaster"), + ("interest_group_admin", "interest_group_admin"), + ("alumni_admin", "alumni_admin"), + ("retiree_email", "retiree_email"), + ("company_admin", "company_admin"), + ("dugnad_admin", "dugnad_admin"), + ("trip_admin", "trip_admin"), + ("sponsor_admin", "sponsor_admin"), + ("social_admin", "social_admin"), + ], + max_length=30, + ), + default=["member"], + size=None, + validators=[ + lego.apps.lending.validators.responsible_roles_validator + ], + ), + ), + ("location", models.CharField(blank=True, max_length=128)), + ( + "can_edit_groups", + models.ManyToManyField( + blank=True, + related_name="can_edit_%(class)s", + to="users.abakusgroup", + ), + ), + ( + "can_edit_users", + models.ManyToManyField( + blank=True, + related_name="can_edit_%(class)s", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "can_view_groups", + models.ManyToManyField( + blank=True, + related_name="can_view_%(class)s", + to="users.abakusgroup", + ), + ), + ( + "created_by", + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "image", + lego.apps.files.models.FileField( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="lendable_object_images", + to="files.file", + ), + ), + ("responsible_groups", models.ManyToManyField(to="users.abakusgroup")), + ( + "updated_by", + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="LendingInstance", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + db_index=True, default=django.utils.timezone.now, editable=False + ), + ), + ( + "updated_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ( + "deleted", + models.BooleanField(db_index=True, default=False, editable=False), + ), + ("require_auth", models.BooleanField(default=True)), + ("start_date", models.DateTimeField(null=True)), + ("end_date", models.DateTimeField(null=True)), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("ACCEPTED", "Accepted"), + ("REJECTED", "Rejected"), + ], + default="PENDING", + max_length=8, + ), + ), + ("message", models.TextField(blank=True)), + ( + "can_edit_groups", + models.ManyToManyField( + blank=True, + related_name="can_edit_%(class)s", + to="users.abakusgroup", + ), + ), + ( + "can_edit_users", + models.ManyToManyField( + blank=True, + related_name="can_edit_%(class)s", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "can_view_groups", + models.ManyToManyField( + blank=True, + related_name="can_view_%(class)s", + to="users.abakusgroup", + ), + ), + ( + "created_by", + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "lendable_object", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="lending.lendableobject", + ), + ), + ( + "updated_by", + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/lego/apps/lending/migrations/__init__.py b/lego/apps/lending/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lego/apps/lending/models.py b/lego/apps/lending/models.py new file mode 100644 index 000000000..83ed6c461 --- /dev/null +++ b/lego/apps/lending/models.py @@ -0,0 +1,73 @@ +from datetime import timedelta, timezone + +from django.contrib.postgres.fields import ArrayField +from django.db import models + +from lego.apps.files.models import FileField +from lego.apps.lending.permissions import ( + LendableObjectPermissionHandler, + LendingInstancePermissionHandler, +) +from lego.apps.lending.validators import responsible_roles_validator +from lego.apps.permissions.models import ObjectPermissionsModel +from lego.apps.users import constants +from lego.utils.models import BasisModel + +# Create your models here. + + +class LendableObject(BasisModel, ObjectPermissionsModel): + title = models.CharField(max_length=128, null=False, blank=False) + description = models.TextField(null=False, blank=True) + has_contract = models.BooleanField(default=False, null=False, blank=False) + max_lending_period = models.DurationField( + null=True, blank=False, default=timedelta(days=7) + ) + responsible_groups = models.ManyToManyField("users.AbakusGroup") + responsible_roles = ArrayField( + models.CharField( + max_length=30, + choices=constants.ROLES, + ), + default=[constants.MEMBER], + validators=[responsible_roles_validator], + ) + image = FileField(related_name="lendable_object_images") + location = models.CharField(max_length=128, null=False, blank=True) + + @property + def get_furthest_booking_date(self): + return timezone.now() + timedelta(days=14) + + class Meta: + abstract = False + permission_handler = LendableObjectPermissionHandler() + + +class LendingInstance(BasisModel, ObjectPermissionsModel): + lendable_object = models.ForeignKey( + LendableObject, on_delete=models.CASCADE, unique=False + ) + start_date = models.DateTimeField(null=True) + end_date = models.DateTimeField(null=True) + + class LendingInstanceStatus(models.TextChoices): + PENDING = "PENDING" + ACCEPTED = "ACCEPTED" + REJECTED = "REJECTED" + + status = models.CharField( + max_length=8, + choices=LendingInstanceStatus.choices, + default=LendingInstanceStatus.PENDING, + ) + + message = models.TextField(null=False, blank=True) + + @property + def active(self): + return timezone.now() < self.end_date and timezone.now() > self.start_date + + class Meta: + abstract = False + permission_handler = LendingInstancePermissionHandler() diff --git a/lego/apps/lending/notifications.py b/lego/apps/lending/notifications.py new file mode 100644 index 000000000..b6530c950 --- /dev/null +++ b/lego/apps/lending/notifications.py @@ -0,0 +1,40 @@ +from lego.apps.lending.models import LendingInstance +from lego.apps.notifications.constants import LENDING_INSTANCE_CREATION +from lego.apps.notifications.notification import Notification +from lego.apps.users.models import User + + +class LendingInstanceCreateNotification(Notification): + def __init__(self, lending_instance: LendingInstance, user: User): + self.lending_instance = lending_instance + self.lender = lending_instance.user + + super().__init__(user=user) + + name = LENDING_INSTANCE_CREATION + + def generate_mail(self): + return self._delay_mail( + to_email=self.user.email_address, + context={ + "lender": self.lender, + "lendable_object": self.lending_instance.lendable_object.title, + "start_date": self.lending_instance.start_date, + "end_date": self.lending_instance.end_date, + }, + subject="Utlån forespørsel", + plain_template="users/email/lending_instance.txt", + html_template="users/email/lending_instance.html", + ) + + def generate_push(self): + return self._delay_push( + template="users/push/lending_instance.txt", + context={ + "lender": self.lender, + "lendable_object": self.lending_instance.lendable_object.title, + "start_date": self.lending_instance.start_date, + "end_date": self.lending_instance.end_date, + }, + instance=self.lending_instance, + ) diff --git a/lego/apps/lending/permissions.py b/lego/apps/lending/permissions.py new file mode 100644 index 000000000..197a25669 --- /dev/null +++ b/lego/apps/lending/permissions.py @@ -0,0 +1,104 @@ +from django.db.models import Q + +from lego.apps.permissions.constants import CREATE, DELETE, EDIT, LIST, VIEW +from lego.apps.permissions.permissions import PermissionHandler + + +class LendableObjectPermissionHandler(PermissionHandler): + force_object_permission_check = False + authentication_map = {LIST: False, VIEW: False} + default_require_auth = True + default_keyword_permission = "/sudo/admin/lendingobject/{perm}/" + + def has_perm( + self, + user, + perm, + obj=None, + queryset=None, + check_keyword_permissions=True, + **kwargs + ): + if not user.is_authenticated: + return False + + if perm == LIST: + return True + # Check object permissions before keywork perms + if obj is not None: + return self.has_object_permissions(user, perm, obj) + + has_perm = super().has_perm( + user, perm, obj, queryset, check_keyword_permissions, **kwargs + ) + + if has_perm: + return True + + return False + + def has_object_permissions(self, user, perm, obj): + if perm == DELETE: + return False + if perm == EDIT and obj.created_by == user: + return True + return True + + +class LendingInstancePermissionHandler(PermissionHandler): + force_object_permission_check = True + authentication_map = {LIST: False, VIEW: False} + default_require_auth = True + force_queryset_filtering = True + default_keyword_permission = "/sudo/admin/lendinginstance/{perm}/" + + def has_perm( + self, + user, + perm, + obj=None, + queryset=None, + check_keyword_permissions=True, + **kwargs + ): + if not user.is_authenticated: + return False + + if perm == LIST: + return True + + # Check object permissions before keywork perms + if obj is not None: + if self.has_object_permissions(user, perm, obj): + return True + + if perm == EDIT and self.created_by(user, obj): + return True + + if perm == CREATE: + return True + + has_perm = super().has_perm( + user, perm, obj, queryset, check_keyword_permissions, **kwargs + ) + + if has_perm: + return True + + return False + + def has_object_permissions(self, user, perm, obj): + if perm == DELETE: + return False + if perm == EDIT and obj.created_by == user: + return True + return not (perm == DELETE or perm == EDIT) + + def filter_queryset(self, user, queryset, **kwargs): + if user.is_authenticated: + user_groups = user.abakus_groups.all() + return queryset.filter( + Q(created_by=user) + | Q(lendable_object__responsible_groups__in=user_groups) + ).distinct() + return queryset.none() diff --git a/lego/apps/lending/serializers.py b/lego/apps/lending/serializers.py new file mode 100644 index 000000000..baef61fca --- /dev/null +++ b/lego/apps/lending/serializers.py @@ -0,0 +1,127 @@ +from rest_framework import serializers + +from lego.apps.files.fields import ImageField +from lego.apps.lending.models import LendableObject, LendingInstance +from lego.apps.users.serializers.users import PublicUserSerializer +from lego.utils.serializers import ( + BasisModelSerializer, + ObjectPermissionsSerializerMixin, +) + + +class DetailedLendableObjectSerializer(BasisModelSerializer): + image = ImageField(required=False, options={"height": 500}) + + class Meta: + model = LendableObject + fields = ( + "id", + "title", + "description", + "has_contract", + "max_lending_period", + "responsible_groups", + "responsible_roles", + "image", + "location", + "created_by", + "updated_by", + ) + + +class DetailedAdminLendableObjectSerializer( + ObjectPermissionsSerializerMixin, DetailedLendableObjectSerializer +): + class Meta: + model = LendableObject + fields = ( + DetailedLendableObjectSerializer.Meta.fields + + ObjectPermissionsSerializerMixin.Meta.fields + ) + + +class LendingInstanceCreateAndUpdateSerializer(BasisModelSerializer): + class Meta: + model = LendingInstance + fields = ( + "id", + "lendable_object", + "start_date", + "end_date", + "message", + ) + + def validate(self, data): + # Check if 'lendable_object', 'start_date', and 'end_date' are provided in the data. + lendable_object = data.get("lendable_object") + start_date = data.get("start_date") + end_date = data.get("end_date") + + # Ensure all necessary fields are present + if lendable_object and start_date and end_date: + user = self.context["request"].user + # Check if the user is in one of the responsible groups + if not user.abakus_groups.filter( + id__in=lendable_object.responsible_groups.values_list("id", flat=True) + ).exists(): + # Calculate the lending period and compare + lending_period = end_date - start_date + max_lending_period = lendable_object.max_lending_period + if lending_period > max_lending_period: + raise serializers.ValidationError( + "Lending period exceeds maximum allowed duration" + ) + + return data + + +class DetailedLendingInstanceSerializer(BasisModelSerializer): + author = PublicUserSerializer(read_only=True, source="created_by") + lendable_object = DetailedLendableObjectSerializer() + + class Meta: + model = LendingInstance + fields = ( + "id", + "lendable_object", + "start_date", + "end_date", + "author", + "status", + "message", + "created_at", + ) + + def validate(self, data): + # Check if 'lendable_object', 'start_date', and 'end_date' are provided in the data. + lendable_object = data.get("lendable_object") + start_date = data.get("start_date") + end_date = data.get("end_date") + + # Ensure all necessary fields are present + if lendable_object and start_date and end_date: + user = self.context["request"].user + # Check if the user is in one of the responsible groups + if not user.abakus_groups.filter( + id__in=lendable_object.responsible_groups.values_list("id", flat=True) + ).exists(): + # Calculate the lending period and compare + lending_period = end_date - start_date + max_lending_period = lendable_object.max_lending_period + if lending_period > max_lending_period: + raise serializers.ValidationError( + "Lending period exceeds maximum allowed duration" + ) + + return data + + +class DetailedAdminLendingInstanceSerializer( + ObjectPermissionsSerializerMixin, DetailedLendingInstanceSerializer +): + class Meta: + model = LendingInstance + fields = ( + DetailedLendingInstanceSerializer.Meta.fields + + ObjectPermissionsSerializerMixin.Meta.fields + ) diff --git a/lego/apps/lending/urls.py b/lego/apps/lending/urls.py new file mode 100644 index 000000000..16fb4d2bf --- /dev/null +++ b/lego/apps/lending/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from .views import LendableObjectViewSet + +urlpatterns = [ + path( + "lendableobject//lendinginstances/", + LendableObjectViewSet.as_view({"get": "lendingInstance"}), + name="lendableobject-lendinginstance", + ), +] diff --git a/lego/apps/lending/validators.py b/lego/apps/lending/validators.py new file mode 100644 index 000000000..c2d0d80d2 --- /dev/null +++ b/lego/apps/lending/validators.py @@ -0,0 +1,6 @@ +from django.forms import ValidationError + + +def responsible_roles_validator(value): + if len(value) != len(set(value)): + raise ValidationError("Duplicate values are not allowed.") diff --git a/lego/apps/lending/views.py b/lego/apps/lending/views.py new file mode 100644 index 000000000..bb78437c4 --- /dev/null +++ b/lego/apps/lending/views.py @@ -0,0 +1,79 @@ +from rest_framework import decorators, permissions, status, viewsets +from rest_framework.response import Response + +from lego.apps.lending.filters import LendableObjectFilterSet, LendingInstanceFilterSet +from lego.apps.lending.models import LendableObject, LendingInstance +from lego.apps.lending.serializers import ( + DetailedAdminLendableObjectSerializer, + DetailedLendableObjectSerializer, + DetailedLendingInstanceSerializer, + LendingInstanceCreateAndUpdateSerializer, +) +from lego.apps.permissions.api.views import AllowedPermissionsMixin +from lego.apps.permissions.utils import get_permission_handler + + +class LendableObjectViewSet(AllowedPermissionsMixin, viewsets.ModelViewSet): + queryset = LendableObject.objects.all() + serializer_class = DetailedLendableObjectSerializer + filterset_class = LendableObjectFilterSet + http_method_names = ["get", "post", "patch", "delete", "head", "options"] + permission_classes = [ + permissions.IsAuthenticated, + ] + + def get_serializer_class(self): + if self.request and self.request.user.is_authenticated: + return DetailedAdminLendableObjectSerializer + return super().get_serializer_class() + + @decorators.action( + detail=True, methods=["get"], permission_classes=[permissions.IsAuthenticated] + ) + def lendingInstance(self, request, pk=None): + try: + lendable_object = LendableObject.objects.get(pk=pk) + except LendableObject.DoesNotExist: + return Response( + {"error": "LendableObject not found"}, status=status.HTTP_404_NOT_FOUND + ) + permission_handler = get_permission_handler(LendingInstance) + lending_instances = permission_handler.filter_queryset( + self.request.user, + LendingInstance.objects.filter(lendable_object=lendable_object), + ) + serializer = DetailedLendingInstanceSerializer( + lending_instances, many=True, context={"request": request} + ) + return Response(serializer.data) + + +class LendingInstanceViewSet(AllowedPermissionsMixin, viewsets.ModelViewSet): + serializer_class = DetailedLendingInstanceSerializer + filterset_class = LendingInstanceFilterSet + http_method_names = ["get", "post", "patch", "delete", "head", "options"] + permission_classes = [ + permissions.IsAuthenticated, + ] + + def get_queryset(self): + if self.request is None: + return LendingInstance.objects.none() + permission_handler = get_permission_handler(LendingInstance) + return permission_handler.filter_queryset( + self.request.user, + LendingInstance.objects.prefetch_related("lendable_object"), + ) + + def get_serializer_class(self): + if self.request and self.request.user.is_authenticated: + return DetailedLendingInstanceSerializer + return super().get_serializer_class() + + def create(self, request): + serializer = LendingInstanceCreateAndUpdateSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(raise_exception=True): + serializer.save() + return Response(data=serializer.data, status=status.HTTP_201_CREATED) diff --git a/lego/apps/notifications/constants.py b/lego/apps/notifications/constants.py index fdf9a5363..2840025ae 100644 --- a/lego/apps/notifications/constants.py +++ b/lego/apps/notifications/constants.py @@ -47,6 +47,10 @@ # Followers REGISTRATION_REMINDER = "registration_reminder" +# Lending + +LENDING_INSTANCE_CREATION = "lending_instance_creation" + NOTIFICATION_TYPES = [ ANNOUNCEMENT, RESTRICTED_MAIL_SENT, @@ -66,6 +70,7 @@ COMPANY_INTEREST_CREATED, INACTIVE_WARNING, DELETED_WARNING, + LENDING_INSTANCE_CREATION, ] NOTIFICATION_CHOICES = [ diff --git a/lego/apps/notifications/migrations/0015_alter_notificationsetting_notification_type.py b/lego/apps/notifications/migrations/0015_alter_notificationsetting_notification_type.py new file mode 100644 index 000000000..fb8f8f944 --- /dev/null +++ b/lego/apps/notifications/migrations/0015_alter_notificationsetting_notification_type.py @@ -0,0 +1,40 @@ +# Generated by Django 4.0.10 on 2024-02-06 20:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0014_alter_notificationsetting_notification_type"), + ] + + operations = [ + migrations.AlterField( + model_name="notificationsetting", + name="notification_type", + field=models.CharField( + choices=[ + ("announcement", "announcement"), + ("restricted_mail_sent", "restricted_mail_sent"), + ("weekly_mail", "weekly_mail"), + ("event_bump", "event_bump"), + ("event_admin_registration", "event_admin_registration"), + ("event_admin_unregistration", "event_admin_unregistration"), + ("event_payment_overdue", "event_payment_overdue"), + ("meeting_invite", "meeting_invite"), + ("meeting_invitation_reminder", "meeting_invitation_reminder"), + ("penalty_creation", "penalty_creation"), + ("comment", "comment"), + ("comment_reply", "comment_reply"), + ("registration_reminder", "registration_reminder"), + ("survey_created", "survey_created"), + ("event_payment_overdue_creator", "event_payment_overdue_creator"), + ("company_interest_created", "company_interest_created"), + ("inactive_warning", "inactive_warning"), + ("deleted_warning", "deleted_warning"), + ("lending_instance_creation", "lending_instance_creation"), + ], + max_length=64, + ), + ), + ] diff --git a/lego/apps/notifications/migrations/0016_alter_notificationsetting_channels.py b/lego/apps/notifications/migrations/0016_alter_notificationsetting_channels.py new file mode 100644 index 000000000..c1105b13c --- /dev/null +++ b/lego/apps/notifications/migrations/0016_alter_notificationsetting_channels.py @@ -0,0 +1,28 @@ +# Generated by Django 4.0.10 on 2024-03-12 18:14 + +import django.contrib.postgres.fields +from django.db import migrations, models + +import lego.apps.notifications.models + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0015_announcement_exclude_waiting_list_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="notificationsetting", + name="channels", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("email", "email"), ("push", "push")], max_length=64 + ), + blank=True, + default=lego.apps.notifications.models._default_channels, + null=True, + size=None, + ), + ), + ] diff --git a/lego/apps/notifications/migrations/0016_merge_20240312_1850.py b/lego/apps/notifications/migrations/0016_merge_20240312_1850.py new file mode 100644 index 000000000..05ad2ea8e --- /dev/null +++ b/lego/apps/notifications/migrations/0016_merge_20240312_1850.py @@ -0,0 +1,12 @@ +# Generated by Django 4.0.10 on 2024-03-12 18:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0015_alter_notificationsetting_notification_type"), + ("notifications", "0015_announcement_exclude_waiting_list_and_more"), + ] + + operations = [] diff --git a/lego/apps/notifications/migrations/0017_merge_20240423_1728.py b/lego/apps/notifications/migrations/0017_merge_20240423_1728.py new file mode 100644 index 000000000..111aea846 --- /dev/null +++ b/lego/apps/notifications/migrations/0017_merge_20240423_1728.py @@ -0,0 +1,12 @@ +# Generated by Django 4.0.10 on 2024-04-23 17:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0016_alter_notificationsetting_channels"), + ("notifications", "0016_merge_20240312_1850"), + ] + + operations = [] diff --git a/lego/apps/notifications/models.py b/lego/apps/notifications/models.py index 87e7598df..96ff2ecf6 100644 --- a/lego/apps/notifications/models.py +++ b/lego/apps/notifications/models.py @@ -31,6 +31,7 @@ class NotificationSetting(models.Model): models.CharField(max_length=64, choices=CHANNEL_CHOICES), default=_default_channels, null=True, + blank=True, ) class Meta: diff --git a/lego/apps/notifications/notification.py b/lego/apps/notifications/notification.py index 0cdec3bbe..abc62d03a 100644 --- a/lego/apps/notifications/notification.py +++ b/lego/apps/notifications/notification.py @@ -1,5 +1,6 @@ from structlog import get_logger +from lego.apps.users.models import User from lego.utils.content_types import instance_to_string from lego.utils.tasks import send_email, send_push @@ -15,7 +16,7 @@ class Notification: name = None - def __init__(self, user, *args, **kwargs): + def __init__(self, user: User, *args, **kwargs): self.user = user self.args = (args,) self.kwargs = kwargs diff --git a/lego/apps/search/backend.py b/lego/apps/search/backend.py index 394fef96c..33451ebde 100644 --- a/lego/apps/search/backend.py +++ b/lego/apps/search/backend.py @@ -1,7 +1,7 @@ from . import registry -class SearchBacked: +class SearchBackend: """ Base class for search backends. A backend needs to implement all methods on this class to work like it should. diff --git a/lego/apps/search/backends/elasticsearch.py b/lego/apps/search/backends/elasticsearch.py index 212480136..86b14104d 100644 --- a/lego/apps/search/backends/elasticsearch.py +++ b/lego/apps/search/backends/elasticsearch.py @@ -6,10 +6,10 @@ from elasticsearch import Elasticsearch, NotFoundError from elasticsearch.helpers import bulk -from lego.apps.search.backend import SearchBacked +from lego.apps.search.backend import SearchBackend -class ElasticsearchBackend(SearchBacked): +class ElasticsearchBackend(SearchBackend): name = "elasticsearch" connection = None diff --git a/lego/apps/search/backends/postgres.py b/lego/apps/search/backends/postgres.py index 98cec12fd..bd0a39a1a 100644 --- a/lego/apps/search/backends/postgres.py +++ b/lego/apps/search/backends/postgres.py @@ -1,9 +1,9 @@ -from lego.apps.search.backend import SearchBacked +from lego.apps.search.backend import SearchBackend from .. import registry -class PostgresBackend(SearchBacked): +class PostgresBackend(SearchBackend): name = "postgres" max_results = 10 diff --git a/lego/apps/stats/analytics_client.py b/lego/apps/stats/analytics_client.py index e7da44a12..0a20c1026 100644 --- a/lego/apps/stats/analytics_client.py +++ b/lego/apps/stats/analytics_client.py @@ -20,6 +20,9 @@ def setup_analytics(): write_key = getattr(settings, "ANALYTICS_WRITE_KEY", "") host = getattr(settings, "ANALYTICS_HOST", "https://api.segment.io") + if write_key == "": + return + production = getattr(settings, "ENVIRONMENT_NAME", None) == "production" send = not (development or getattr(settings, "TESTING", False)) or production @@ -36,6 +39,9 @@ def setup_analytics(): def _proxy(method, user, *args, **kwargs): global default_client, development + if default_client is None: + return + fn = getattr(default_client, method) kwargs["context"] = { diff --git a/lego/apps/tags/tests/test_views.py b/lego/apps/tags/tests/test_views.py index a9f22313c..16e3d1ec8 100644 --- a/lego/apps/tags/tests/test_views.py +++ b/lego/apps/tags/tests/test_views.py @@ -13,9 +13,7 @@ def test_fetch_popular(self): def test_fetch_list(self): response = self.client.get("/api/v1/tags/") self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertDictEqual( - response.json()["results"][0], {"tag": "ababrygg", "usages": 0} - ) + self.assertDictEqual(response.json()[0], {"tag": "ababrygg", "usages": 0}) def test_fetch_detail(self): response = self.client.get("/api/v1/tags/ababrygg/") diff --git a/lego/apps/tags/views.py b/lego/apps/tags/views.py index ea54def28..7fe3d4829 100644 --- a/lego/apps/tags/views.py +++ b/lego/apps/tags/views.py @@ -12,10 +12,11 @@ class TagViewSet( RetrieveModelMixin, viewsets.GenericViewSet, ): - queryset = Tag.objects.all() + queryset = Tag.objects.all().order_by("tag") ordering = "tag" serializer_class = TagListSerializer permission_classes = [permissions.AllowAny] + pagination_class = None def get_serializer_class(self): if self.action != "list": diff --git a/lego/apps/users/constants.py b/lego/apps/users/constants.py index 7f10738de..899ff7242 100644 --- a/lego/apps/users/constants.py +++ b/lego/apps/users/constants.py @@ -1,5 +1,7 @@ from enum import Enum +from django.db import models + MALE = "male" FEMALE = "female" OTHER = "other" @@ -95,13 +97,12 @@ def values(cls) -> list[str]: FSGroup.MSSECCLO: FOURTH_GRADE_KOMTEK, } -STUDENT_EMAIL_DOMAIN = "stud.ntnu.no" - GROUP_COMMITTEE = "komite" GROUP_INTEREST = "interesse" GROUP_BOARD = "styre" GROUP_REVUE = "revy" GROUP_SUB = "under" +GROUP_ORDAINED = "ordenen" GROUP_GRADE = "klasse" GROUP_OTHER = "annen" GROUP_TYPES = ( @@ -112,6 +113,7 @@ def values(cls) -> list[str]: (GROUP_GRADE, GROUP_GRADE), (GROUP_OTHER, GROUP_OTHER), (GROUP_SUB, GROUP_SUB), + (GROUP_ORDAINED, GROUP_ORDAINED), ) OPEN_GROUPS = (GROUP_INTEREST,) @@ -142,3 +144,16 @@ def values(cls) -> list[str]: (LIGHT_THEME, LIGHT_THEME), (DARK_THEME, DARK_THEME), ) + + +LATE_PRESENCE_PENALTY_WEIGHT = 1 + + +class PENALTY_WEIGHTS(models.TextChoices): + LATE_PRESENCE = 1 + + +class PENALTY_TYPES(models.TextChoices): + PRESENCE = "presence" + PAYMENT = "payment" + OTHER = "other" diff --git a/lego/apps/users/filters.py b/lego/apps/users/filters.py index 0435a1379..575224c08 100644 --- a/lego/apps/users/filters.py +++ b/lego/apps/users/filters.py @@ -37,6 +37,8 @@ def user__fullname(self, queryset, name, value): class AbakusGroupFilterSet(FilterSet): + type = CharInFilter(field_name="type", lookup_expr="in") + class Meta: model = AbakusGroup fields = ("type",) diff --git a/lego/apps/users/fixtures/initial_abakus_groups.py b/lego/apps/users/fixtures/initial_abakus_groups.py index e2394af7b..12a077b3d 100644 --- a/lego/apps/users/fixtures/initial_abakus_groups.py +++ b/lego/apps/users/fixtures/initial_abakus_groups.py @@ -30,11 +30,12 @@ { "description": "Medlemmer av Abakus", "permissions": [ - "/sudo/admin/meetings/create", - "/sudo/admin/meetinginvitations/create", + "/sudo/admin/meetings/create/", + "/sudo/admin/meetinginvitations/create/", "/sudo/admin/registrations/create/", "/sudo/admin/events/payment/", - "/sudo/admin/comments/create", + "/sudo/admin/comments/create/", + "/sudo/admin/meetings/list/", ], }, { diff --git a/lego/apps/users/fixtures/initial_abakus_groups.yaml b/lego/apps/users/fixtures/initial_abakus_groups.yaml index daf18097f..2ec2811f6 100644 --- a/lego/apps/users/fixtures/initial_abakus_groups.yaml +++ b/lego/apps/users/fixtures/initial_abakus_groups.yaml @@ -30,8 +30,8 @@ logo: null type: annen text: '' - permissions: '["/sudo/admin/meetings/create", "/sudo/admin/meetinginvitations/create", - "/sudo/admin/registrations/create/", "/sudo/admin/events/payment/", "/sudo/admin/comments/create"]' + permissions: '["/sudo/admin/meetings/create/", "/sudo/admin/meetinginvitations/create/", + "/sudo/admin/registrations/create/", "/sudo/admin/events/payment/", "/sudo/admin/comments/create/", "/sudo/admin/meetings/list/"]' show_badge: true active: true lft: 1 diff --git a/lego/apps/users/fixtures/test_abakus_groups.yaml b/lego/apps/users/fixtures/test_abakus_groups.yaml index ef363cd84..89c6ebd34 100644 --- a/lego/apps/users/fixtures/test_abakus_groups.yaml +++ b/lego/apps/users/fixtures/test_abakus_groups.yaml @@ -325,7 +325,7 @@ logo: null type: annen text: '' - permissions: '["/sudo/admin/meetings/create", "/sudo/admin/meetinginvitations/create", + permissions: '["/sudo/admin/meetings/create/", "/sudo/admin/meetinginvitations/create/", "/sudo/admin/registrations/create/", "/sudo/admin/events/payment/"]' show_badge: true active: true diff --git a/lego/apps/users/migrations/0042_penalty_type.py b/lego/apps/users/migrations/0042_penalty_type.py new file mode 100644 index 000000000..7b4f6af4f --- /dev/null +++ b/lego/apps/users/migrations/0042_penalty_type.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.10 on 2024-03-14 10:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0041_user_linkedin_id_alter_user_github_username"), + ] + + operations = [ + migrations.AddField( + model_name="penalty", + name="type", + field=models.CharField( + choices=[ + ("presence", "Presence"), + ("payment", "Payment"), + ("other", "Other"), + ], + default="other", + max_length=50, + ), + ), + ] diff --git a/lego/apps/users/migrations/0043_alter_abakusgroup_type.py b/lego/apps/users/migrations/0043_alter_abakusgroup_type.py new file mode 100644 index 000000000..bbb5ca237 --- /dev/null +++ b/lego/apps/users/migrations/0043_alter_abakusgroup_type.py @@ -0,0 +1,30 @@ +# Generated by Django 4.0.10 on 2024-03-20 00:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0042_penalty_type"), + ] + + operations = [ + migrations.AlterField( + model_name="abakusgroup", + name="type", + field=models.CharField( + choices=[ + ("komite", "komite"), + ("interesse", "interesse"), + ("styre", "styre"), + ("revy", "revy"), + ("klasse", "klasse"), + ("annen", "annen"), + ("under", "under"), + ("ordenen", "ordenen"), + ], + default="annen", + max_length=10, + ), + ), + ] diff --git a/lego/apps/users/models.py b/lego/apps/users/models.py index d5797c3f9..7752a8b8a 100644 --- a/lego/apps/users/models.py +++ b/lego/apps/users/models.py @@ -537,6 +537,11 @@ class Penalty(BasisModel): source_event = models.ForeignKey( "events.Event", related_name="penalties", on_delete=models.CASCADE ) + type = models.CharField( + max_length=50, + choices=constants.PENALTY_TYPES.choices, + default=constants.PENALTY_TYPES.OTHER, + ) objects = UserPenaltyManager() # type: ignore diff --git a/lego/apps/users/templates/users/email/lending_instance.html b/lego/apps/users/templates/users/email/lending_instance.html new file mode 100644 index 000000000..06bdbd27d --- /dev/null +++ b/lego/apps/users/templates/users/email/lending_instance.html @@ -0,0 +1,31 @@ +{% extends "email/base.html" %} + +{% block alert %} + + + + Det er en ny forespørsel om å låne {{ lendable_object }}! + + + +{% endblock %} + +{% block content %} + + + + Bruker: {{ lender }}! + + + + + + + + Fra: {{ start_date }}, Til: {{ end_date }} + + + + + +{% endblock %} \ No newline at end of file diff --git a/lego/apps/users/templates/users/email/lending_instance.txt b/lego/apps/users/templates/users/email/lending_instance.txt new file mode 100644 index 000000000..59b087f55 --- /dev/null +++ b/lego/apps/users/templates/users/email/lending_instance.txt @@ -0,0 +1,14 @@ +{% extends "email/base.txt" %} + +{% block content %} + +Det er en ny forespørsel om å låne {{ lendable_object }}! + +Bruker: {{ lender }}! + +Fra: {{ start_date }}, Til: {{ end_date }} + + + + +{% endblock %} diff --git a/lego/apps/users/templates/users/push/lending_instance.txt b/lego/apps/users/templates/users/push/lending_instance.txt new file mode 100644 index 000000000..a7d604c5c --- /dev/null +++ b/lego/apps/users/templates/users/push/lending_instance.txt @@ -0,0 +1,5 @@ +Det er en ny forespørsel om å låne {{ lendable_object }}! + +Bruker: {{ lender }}! + +Fra: {{ start_date }}, Til: {{ end_date }} diff --git a/lego/apps/users/tests/test_abakusgroup_api.py b/lego/apps/users/tests/test_abakusgroup_api.py index aedd5272a..f5db93824 100644 --- a/lego/apps/users/tests/test_abakusgroup_api.py +++ b/lego/apps/users/tests/test_abakusgroup_api.py @@ -2,7 +2,7 @@ from rest_framework import status from lego.apps.users import constants -from lego.apps.users.constants import LEADER +from lego.apps.users.constants import GROUP_COMMITTEE, GROUP_INTEREST, LEADER from lego.apps.users.models import AbakusGroup, User from lego.apps.users.serializers.abakus_groups import PublicAbakusGroupSerializer from lego.utils.test_utils import BaseAPITestCase @@ -67,6 +67,15 @@ def test_without_auth(self): def test_with_auth(self): self.successful_list(self.user) + def test_with_filter_type(self): + """Groups can be filtered on multiple types""" + self.client.force_authenticate(self.user) + response = self.client.get( + f"{_get_list_url()}?type={GROUP_COMMITTEE},{GROUP_INTEREST}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(7, len(response.json()["results"])) + class RetrieveAbakusGroupAPITestCase(BaseAPITestCase): fixtures = ["test_abakus_groups.yaml", "test_users.yaml"] diff --git a/lego/apps/users/tests/test_password_change.py b/lego/apps/users/tests/test_password_change.py index fb610739f..cc94de75c 100644 --- a/lego/apps/users/tests/test_password_change.py +++ b/lego/apps/users/tests/test_password_change.py @@ -47,6 +47,7 @@ def test_new_password_not_valid(self): self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) def test_new_password_success(self): + old_password_hash = self.user.crypt_password_hash self.client.force_authenticate(self.user) response = self.client.post( self.url, @@ -60,3 +61,4 @@ def test_new_password_success(self): self.assertTrue( authenticate(username="test1", password="new_secret_password123") ) + self.assertNotEqual(self.user.crypt_password_hash, old_password_hash) diff --git a/lego/apps/users/tests/test_users_api.py b/lego/apps/users/tests/test_users_api.py index 098a683c3..235a03c45 100644 --- a/lego/apps/users/tests/test_users_api.py +++ b/lego/apps/users/tests/test_users_api.py @@ -232,6 +232,7 @@ def test_with_valid_data(self): self.assertEqual(new_user.is_superuser, False) self.assertEqual(new_user.is_abakus_member, False) self.assertEqual(new_user.is_abakom_member, False) + self.assertNotEqual(new_user.crypt_password_hash, "") # Test member groups user_group = AbakusGroup.objects.get(name=constants.USER_GROUP) diff --git a/lego/apps/users/views/users.py b/lego/apps/users/views/users.py index d7d5a8994..aba848973 100644 --- a/lego/apps/users/views/users.py +++ b/lego/apps/users/views/users.py @@ -116,6 +116,8 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) user = User.objects.create_user(email=token_email, **serializer.validated_data) + user.set_password(serializer.validated_data["password"]) + user.save() user_group = AbakusGroup.objects.get(name=constants.USER_GROUP) user_group.add_user(user) diff --git a/lego/settings/base.py b/lego/settings/base.py index b340b8772..2480c4afc 100644 --- a/lego/settings/base.py +++ b/lego/settings/base.py @@ -66,6 +66,7 @@ "lego.apps.tags", "lego.apps.users", "lego.apps.websockets", + "lego.apps.lending", ] DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/lego/utils/management/commands/load_fixtures.py b/lego/utils/management/commands/load_fixtures.py index 264d8608a..09830e126 100644 --- a/lego/utils/management/commands/load_fixtures.py +++ b/lego/utils/management/commands/load_fixtures.py @@ -78,6 +78,8 @@ def run(self, *args, **options): "joblistings/fixtures/development_joblistings.yaml", "surveys/fixtures/development_surveys.yaml", "users/fixtures/development_photo_consents.yaml", + "lending/fixtures/development_lendable_objects.yaml", + "lending/fixtures/development_lending_requests.yaml", ] )