diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 9120a984..c04a438c 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,18 +1,6 @@ - - sqlite.xerial - true - org.sqlite.JDBC - jdbc:sqlite:C:\Users\antek\PycharmProjects\eproba\eproba\db.sqlite3 - $ProjectFileDir$ - - - file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.34.0/sqlite-jdbc-3.34.0.jar - - - sqlite.xerial true diff --git a/eproba/apps/core/templates/core/base.html b/eproba/apps/core/templates/core/base.html index d51b9601..f2ba1883 100644 --- a/eproba/apps/core/templates/core/base.html +++ b/eproba/apps/core/templates/core/base.html @@ -87,10 +87,10 @@ right: 0; background: red; color: white; - padding: 10px 40px; + padding: 4px 50px; font-weight: bold; - font-size: 14px; - transform: rotate(45deg) translate(35px, -10px); + font-size: 16px; + transform: rotate(45deg) translate(43px, -10px); transform-origin: top right; z-index: 9999; box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.3); diff --git a/eproba/apps/core/templates/core/navbar.html b/eproba/apps/core/templates/core/navbar.html index a58b7949..063d7417 100644 --- a/eproba/apps/core/templates/core/navbar.html +++ b/eproba/apps/core/templates/core/navbar.html @@ -88,6 +88,9 @@ {% endif %} + + Wiki + +
+ +
+
+ {{ form.organization }} +
+
+
+
diff --git a/eproba/apps/teams/views/team_request.py b/eproba/apps/teams/views/team_request.py index 2db11780..04ee2e0d 100644 --- a/eproba/apps/teams/views/team_request.py +++ b/eproba/apps/teams/views/team_request.py @@ -1,4 +1,7 @@ +import threading + from django.contrib import messages +from django.core.mail import EmailMessage from django.shortcuts import redirect, render from django.urls import reverse @@ -6,6 +9,17 @@ from ..models import District, Team, TeamRequest +def send_team_request_email(team_request_obj): + email = EmailMessage( + subject=f"Zgłoszenie o dodanie drużyny: {team_request_obj.team.name}", + body="Pojawiło się nowe zgłoszenie o dodanie drużyny. https://eproba.zhr.pl/team/requests/", + from_email=None, + to=["eproba@zhr.pl"], + headers={"Reply-To": team_request_obj.created_by.email}, + ) + email.send() + + def team_request(request): if not request.user.is_authenticated: return render(request, "teams/team_request_unauthorized.html") @@ -32,6 +46,7 @@ def team_request(request): name=request.POST.get("team_name"), short_name=request.POST.get("team_short_name"), district=District.objects.get(id=request.POST.get("district")), + organization=int(request.POST.get("organization", 0)), is_verified=False, ) @@ -46,12 +61,17 @@ def team_request(request): request.user.save() - TeamRequest.objects.create( + team_request_obj = TeamRequest.objects.create( created_by=request.user, team=team, function_level=request.POST.get("function_level"), ) + send_email_thread = threading.Thread( + target=send_team_request_email, args={team_request_obj}, daemon=True + ) + send_email_thread.start() + return redirect(reverse("teams:team_request_success")) else: diff --git a/eproba/apps/users/api/__init__.py b/eproba/apps/users/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eproba/apps/users/permissions.py b/eproba/apps/users/api/permissions.py similarity index 100% rename from eproba/apps/users/permissions.py rename to eproba/apps/users/api/permissions.py diff --git a/eproba/apps/users/serializers.py b/eproba/apps/users/api/serializers.py similarity index 97% rename from eproba/apps/users/serializers.py rename to eproba/apps/users/api/serializers.py index fe09cc76..14858ff8 100644 --- a/eproba/apps/users/serializers.py +++ b/eproba/apps/users/api/serializers.py @@ -1,7 +1,6 @@ +from apps.users.models import User from rest_framework import serializers -from .models import User - class UserSerializer(serializers.ModelSerializer): team = serializers.UUIDField( diff --git a/eproba/apps/users/api/views.py b/eproba/apps/users/api/views.py new file mode 100644 index 00000000..8b527c6d --- /dev/null +++ b/eproba/apps/users/api/views.py @@ -0,0 +1,54 @@ +from apps.users.models import User +from rest_framework import mixins, viewsets +from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .permissions import IsAllowedToManageUserOrReadOnly +from .serializers import PublicUserSerializer, UserSerializer + + +class UserViewSet( + mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet +): + permission_classes = [IsAuthenticated, IsAllowedToManageUserOrReadOnly] + serializer_class = PublicUserSerializer + + def get_queryset(self): + # Check if a list of IDs is provided in the query string + ids = self.request.query_params.get("ids") + if ids: + # Expect a comma-separated list of IDs (UUIDs) + user_ids = [uid.strip() for uid in ids.split(",") if uid.strip()] + return User.objects.filter(id__in=user_ids) + + team_id = self.request.query_params.get("team") + if team_id is not None: + return User.objects.filter(patrol__team_id=team_id) + + if self.request.user.patrol is None: + return User.objects.none() + + return User.objects.filter(patrol__team_id=self.request.user.patrol.team.id) + + def perform_update(self, serializer): + # Example check for promotion restrictions + data = serializer.validated_data.get("user") + if data and data.get("function", 0) > self.request.user.function: + from rest_framework.exceptions import PermissionDenied + + raise PermissionDenied("Cannot assign a higher function than your own.") + serializer.save() + + def retrieve(self, request, *args, **kwargs): + instance = get_object_or_404(User, pk=kwargs["pk"]) + serializer = self.get_serializer(instance) + return Response(serializer.data) + + +class UserInfo(viewsets.ModelViewSet): + serializer_class = UserSerializer + permission_classes = [IsAuthenticated] + + def list(self, request, *args, **kwargs): + return Response(self.get_serializer(request.user).data) diff --git a/eproba/apps/users/models.py b/eproba/apps/users/models.py index 5fc81eb2..f0c83485 100644 --- a/eproba/apps/users/models.py +++ b/eproba/apps/users/models.py @@ -70,7 +70,7 @@ class Meta: verbose_name_plural = "Użytkownicy" def __str__(self): - return f"{self.email} ({self.nickname})" + return self.full_name_nickname() or self.email def get_short_name(self): return self.full_name() or self.email.split("@")[0] @@ -120,6 +120,10 @@ def rank(self): else "" ) + _gender = ( + self.patrol.team.organization if self.patrol is not None else self.gender + ) + scout_rank_male = { 1: "biszkopt", 2: "mł.", @@ -138,9 +142,9 @@ def rank(self): 6: "HR", } - if self.gender == 0: + if _gender == 0: scout_rank = scout_rank_male.get(self.scout_rank, "") - elif self.gender == 1: + elif _gender == 1: scout_rank = scout_rank_female.get(self.scout_rank, "") else: scout_rank = self.get_scout_rank_display() @@ -149,6 +153,10 @@ def rank(self): def full_rank(self): + _gender = ( + self.patrol.team.organization if self.patrol is not None else self.gender + ) + instructor_rank_male = {1: "przewodnik", 2: "podharcmistrz", 3: "harcmistrz"} instructor_rank_female = { @@ -175,10 +183,10 @@ def full_rank(self): 6: "harcerka Rzeczypospolitej", } - if self.gender == 0: + if _gender == 0: instructor_rank = instructor_rank_male.get(self.instructor_rank, "") scout_rank = scout_rank_male.get(self.scout_rank, "") - elif self.gender == 1: + elif _gender == 1: instructor_rank = instructor_rank_female.get(self.instructor_rank, "") scout_rank = scout_rank_female.get(self.scout_rank, "") else: diff --git a/eproba/apps/users/templates/users/view_profile.html b/eproba/apps/users/templates/users/view_profile.html index ab95eac3..2c5ec2ee 100644 --- a/eproba/apps/users/templates/users/view_profile.html +++ b/eproba/apps/users/templates/users/view_profile.html @@ -2,7 +2,7 @@ {% load static %} {% block extrahead %} - + {% endblock %} {% block title %}Profil - {{ user.nickname }} - Epróba{% endblock %} {% block content %} diff --git a/eproba/apps/wiki/__init__.py b/eproba/apps/wiki/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eproba/apps/wiki/admin.py b/eproba/apps/wiki/admin.py new file mode 100644 index 00000000..8558750e --- /dev/null +++ b/eproba/apps/wiki/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin +from treebeard.admin import TreeAdmin +from treebeard.forms import movenodeform_factory + +from .models import Folder, Page + + +# Folder Admin with Tree Support +class FolderAdmin(TreeAdmin): + form = movenodeform_factory(Folder) + list_display = ("name", "owner", "visible", "created_at", "updated_at") + list_filter = ("visible", "created_at", "updated_at") + search_fields = ("name",) + + +# Page Admin +@admin.register(Page) +class PageAdmin(admin.ModelAdmin): + list_display = ("title", "folder", "owner", "visible", "created_at", "updated_at") + list_filter = ("visible", "created_at", "updated_at", "folder") + search_fields = ("title", "content") + ordering = ("title",) + + +# Register Folder with TreeAdmin +admin.site.register(Folder, FolderAdmin) diff --git a/eproba/apps/wiki/api/serializers.py b/eproba/apps/wiki/api/serializers.py new file mode 100644 index 00000000..946d7d33 --- /dev/null +++ b/eproba/apps/wiki/api/serializers.py @@ -0,0 +1,14 @@ +from apps.wiki.models import Folder, Page +from rest_framework import serializers + + +class FolderSerializer(serializers.ModelSerializer): + class Meta: + model = Folder + fields = "__all__" + + +class PageSerializer(serializers.ModelSerializer): + class Meta: + model = Page + fields = "__all__" diff --git a/eproba/apps/wiki/api/views.py b/eproba/apps/wiki/api/views.py new file mode 100644 index 00000000..e69de29b diff --git a/eproba/apps/wiki/apps.py b/eproba/apps/wiki/apps.py new file mode 100644 index 00000000..48569e55 --- /dev/null +++ b/eproba/apps/wiki/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WikiConfig(AppConfig): + name = "apps.wiki" + verbose_name = "Wiki" diff --git a/eproba/apps/wiki/forms.py b/eproba/apps/wiki/forms.py new file mode 100644 index 00000000..99d44719 --- /dev/null +++ b/eproba/apps/wiki/forms.py @@ -0,0 +1,19 @@ +# wiki/forms.py +from django import forms +from tinymce.widgets import TinyMCE + +from .models import Page + + +class PageForm(forms.ModelForm): + class Meta: + model = Page + fields = ["title", "content"] + widgets = { + "title": forms.TextInput( + attrs={"class": "input", "placeholder": "Wprowadź tytuł strony"} + ), + "content": TinyMCE( + attrs={"class": "textarea", "placeholder": "Wprowadź treść strony"} + ), + } diff --git a/eproba/apps/wiki/migrations/0001_initial.py b/eproba/apps/wiki/migrations/0001_initial.py new file mode 100644 index 00000000..937d9703 --- /dev/null +++ b/eproba/apps/wiki/migrations/0001_initial.py @@ -0,0 +1,177 @@ +# Generated by Django 5.1.6 on 2025-02-21 21:15 + +import uuid + +import django.db.models.deletion +import tinymce.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("teams", "0006_team_organization"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Folder", + fields=[ + ("path", models.CharField(max_length=255, unique=True)), + ("depth", models.PositiveIntegerField()), + ("numchild", models.PositiveIntegerField(default=0)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("visible", models.BooleanField(default=True)), + ("inherit_permissions", models.BooleanField(default=True)), + ("for_all", models.BooleanField(default=False)), + ( + "organization", + models.IntegerField( + blank=True, + choices=[ + (0, "Organizacja Harcerzy"), + (1, "Organizacja Harcerek"), + ], + null=True, + ), + ), + ( + "district", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="teams.district", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "patrol", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="teams.patrol", + ), + ), + ( + "team", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="teams.team", + ), + ), + ], + options={ + "verbose_name": "Folder", + "verbose_name_plural": "Folders", + }, + ), + migrations.CreateModel( + name="Page", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("title", models.CharField(max_length=255)), + ("content", tinymce.models.HTMLField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("visible", models.BooleanField(default=True)), + ("inherit_permissions", models.BooleanField(default=True)), + ("for_all", models.BooleanField(default=False)), + ( + "organization", + models.IntegerField( + blank=True, + choices=[ + (0, "Organizacja Harcerzy"), + (1, "Organizacja Harcerek"), + ], + null=True, + ), + ), + ( + "district", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="teams.district", + ), + ), + ( + "folder", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="pages", + to="wiki.folder", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "patrol", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="teams.patrol", + ), + ), + ( + "team", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="teams.team", + ), + ), + ], + options={ + "verbose_name": "Page", + "verbose_name_plural": "Pages", + }, + ), + ] diff --git a/eproba/apps/wiki/migrations/__init__.py b/eproba/apps/wiki/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eproba/apps/wiki/models.py b/eproba/apps/wiki/models.py new file mode 100644 index 00000000..fb6b847e --- /dev/null +++ b/eproba/apps/wiki/models.py @@ -0,0 +1,196 @@ +import uuid + +from django.db import models +from django.db.models import UUIDField +from django.urls import reverse +from tinymce.models import HTMLField +from treebeard.mp_tree import MP_Node + +from ..teams.models import District, OrganizationChoice, Patrol, Team +from ..users.models import User + + +class Folder(MP_Node): + id = UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=255) + owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + visible = models.BooleanField(default=True) + + inherit_permissions = models.BooleanField(default=True) + for_all = models.BooleanField(default=False) + organization = models.IntegerField( + choices=OrganizationChoice.choices, null=True, blank=True + ) + district = models.ForeignKey( + District, null=True, blank=True, on_delete=models.CASCADE + ) + team = models.ForeignKey(Team, null=True, blank=True, on_delete=models.CASCADE) + patrol = models.ForeignKey(Patrol, null=True, blank=True, on_delete=models.CASCADE) + + node_order_by = ["name"] # Automatically orders nodes alphabetically + + class Meta: + verbose_name = "Folder" + verbose_name_plural = "Folders" + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("wiki:folder", kwargs={"pk": self.pk}) + + # Optimized permission check + def can_view(self, user: User, for_list=False): + if ( + self.for_all + or self.owner == user + or user.is_staff + and user.has_perm("wiki.view_folder") + ): + return not for_list + + user_patrol = getattr(user, "patrol", None) + user_team = getattr(user_patrol, "team", None) if user_patrol else None + + # Check this folder + if ( + ( + self.organization + and user_team + and user_team.organization == self.organization + ) + or (self.district and user_team and user_team.district == self.district) + or (self.team and user_team and user_team == self.team) + or (self.patrol and user_patrol and user_patrol == self.patrol) + ): + return True + + # Check all its parent folders + if self.inherit_permissions: + for folder in self.get_ancestors(): + if ( + ( + folder.organization + and user_team + and user_team.organization == folder.organization + ) + or ( + folder.district + and user_team + and user_team.district == folder.district + ) + or (folder.team and user_team and user_team == folder.team) + or (folder.patrol and user_patrol and user_patrol == folder.patrol) + ): + return True + + return False + + # Optimized edit permission check + def can_edit(self, user: User): + if self.owner == user or user.is_staff and user.has_perm("wiki.change_folder"): + return True + + if self.team and user.patrol and user.patrol.team == self.team: + if user.function >= 3: + return True + elif user.function >= 2 and self.patrol == user.patrol: + return True + + # Check the entire hierarchy for edit permission + if self.inherit_permissions: + for folder in self.get_ancestors(): + if folder.owner == user: + return True + if folder.team and user.patrol and user.patrol.team == folder.team: + if user.function >= 3: + return True + elif user.function >= 2 and folder.patrol == user.patrol: + return True + + return False + + def get_pages(self): + return self.pages.all() + + def get_content(self): + return self.get_children(), self.get_pages() + + def get_content_count(self): + return self.get_children().count(), self.get_pages().count() + + def get_content_count_combined(self): + return self.get_children().count() + self.get_pages().count() + + def is_empty(self): + return not self.get_content_count_combined() + + def get_path(self): + return self.get_ancestors() + + +class Page(models.Model): + id = UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + title = models.CharField(max_length=255) + content = HTMLField() + folder = models.ForeignKey( + Folder, on_delete=models.CASCADE, related_name="pages", null=True, blank=True + ) + owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + visible = models.BooleanField(default=True) + + inherit_permissions = models.BooleanField(default=True) + for_all = models.BooleanField(default=False) + organization = models.IntegerField( + choices=OrganizationChoice.choices, null=True, blank=True + ) + district = models.ForeignKey( + District, null=True, blank=True, on_delete=models.CASCADE + ) + team = models.ForeignKey(Team, null=True, blank=True, on_delete=models.CASCADE) + patrol = models.ForeignKey(Patrol, null=True, blank=True, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Page" + verbose_name_plural = "Pages" + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("wiki:page", kwargs={"pk": self.pk}) + + def can_view(self, user: User): + if ( + self.for_all + or self.owner == user + or user.is_staff + and user.has_perm("wiki.view_page") + ): + return True + + user_patrol = getattr(user, "patrol", None) + user_team = getattr(user_patrol, "team", None) if user_patrol else None + + return ( + ( + self.organization + and user_team + and user_team.organization == self.organization + ) + or (self.district and user_team and user_team.district == self.district) + or (self.team and user_team and user_team == self.team) + or (self.patrol and user_patrol and user_patrol == self.patrol) + or (self.folder and self.inherit_permissions and self.folder.can_view(user)) + ) + + def can_edit(self, user: User): + if self.owner == user or user.is_staff and user.has_perm("wiki.change_page"): + return True + if self.folder: + return self.folder.can_edit(user) + return False diff --git a/eproba/apps/wiki/templates/wiki/create_page.html b/eproba/apps/wiki/templates/wiki/create_page.html new file mode 100644 index 00000000..7e739160 --- /dev/null +++ b/eproba/apps/wiki/templates/wiki/create_page.html @@ -0,0 +1,70 @@ +{% extends "core/base.html" %} +{% load static %} +{% block extrahead %} + + +{% endblock %} +{% block content %} + +

Stwórz stronę

+
+ {% csrf_token %} + {% if folder %} +
+ +
+ +
+
+ {% endif %} + +
+ +
+ {{ form.title }} +
+ {% if form.title.errors %} +

{{ form.title.errors }}

+ {% endif %} +
+ +
+ +
+ {{ form.content }} +
+ {% if form.content.errors %} +

{{ form.content.errors }}

+ {% endif %} +
+ +
+
+ +
+
+ Anuluj +
+
+
+{% endblock %} \ No newline at end of file diff --git a/eproba/apps/wiki/templates/wiki/edit_page.html b/eproba/apps/wiki/templates/wiki/edit_page.html new file mode 100644 index 00000000..bb3af854 --- /dev/null +++ b/eproba/apps/wiki/templates/wiki/edit_page.html @@ -0,0 +1,73 @@ +{% extends "core/base.html" %} +{% load static %} +{% block extrahead %} + + +{% endblock %} +{% block content %} + +

Edytuj stronę

+
+ {% csrf_token %} + {% if folder %} +
+ +
+ +
+
+ {% endif %} + +
+ +
+ {{ form.title }} +
+ {% if form.title.errors %} +

{{ form.title.errors }}

+ {% endif %} +
+ +
+ +
+ {{ form.content }} +
+ {% if form.content.errors %} +

{{ form.content.errors }}

+ {% endif %} +
+ +
+
+ +
+
+ Anuluj +
+
+
+{% endblock %} \ No newline at end of file diff --git a/eproba/apps/wiki/templates/wiki/index.html b/eproba/apps/wiki/templates/wiki/index.html new file mode 100644 index 00000000..92aa89d2 --- /dev/null +++ b/eproba/apps/wiki/templates/wiki/index.html @@ -0,0 +1,204 @@ +{% extends "core/base.html" %} + +{% block content %} + + + + {% if current_folder %} +

{{ current_folder.name }}

+ {% endif %} + + +
+ {% for folder in folders %} +
+
+ {% if folder.is_empty %} + + {% else %} + + {% endif %} +
+
+

{{ folder.name }}

+

+ {% if not folder.is_empty %} + {{ folder.get_content_count_combined }} w środku + {% else %} + Pusty + {% endif %} +

+
+
+ + {% for child in folder.get_children.all %} +
+
+ {% if child.is_empty %} + + {% else %} + + {% endif %} +
+
+

{{ child.name }}

+

+ {% if not child.is_empty %} + {{ child.get_content_count_combined }} w środku + {% else %} + Pusty + {% endif %} +

+
+
+ {% endfor %} + + {% for page in folder.pages.all %} +
+
+ +
+
+

{{ page.title }}

+

{{ page.created_at }}

+
+
+ {% endfor %} + {% endfor %} + + {% for page in pages %} +
+
+ +
+
+

{{ page.title }}

+

{{ page.created_at }}

+
+
+ {% endfor %} + + {% if not folders and not pages %} +

Brak zawartości

+ {% endif %} + + {% if allow_wiki_init %} +
+ +

Utwórz wiki dla swojej drużyny

+
+ {% endif %} +
+ + + {% if allow_edit or allow_wiki_init %} + + {% endif %} + + + {% if allow_edit and current_folder %} + + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/eproba/apps/wiki/templates/wiki/page.html b/eproba/apps/wiki/templates/wiki/page.html new file mode 100644 index 00000000..23b258ad --- /dev/null +++ b/eproba/apps/wiki/templates/wiki/page.html @@ -0,0 +1,35 @@ +{% extends "core/base.html" %} +{% block content %} + +

{{ page.title }}

+
+
{{ page.content | safe }} +
+
+{% endblock %} diff --git a/eproba/apps/wiki/urls.py b/eproba/apps/wiki/urls.py new file mode 100644 index 00000000..adbfe801 --- /dev/null +++ b/eproba/apps/wiki/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from . import views + +app_name = "wiki" +urlpatterns = [ + path("", views.folder, name="index"), + path("folder//", views.folder, name="folder"), + path( + "folder//create-folder/", + views.create_folder, + name="create_folder", + ), + path("folder//create-page/", views.create_page, name="create_page"), + path("page//", views.page, name="page"), + path("create-folder/", views.create_folder, name="create_root_folder"), + path("create-page/", views.create_page, name="create_root_page"), + path("edit-folder//", views.edit_folder, name="edit_folder"), + path("edit-page//", views.edit_page, name="edit_page"), + path("init-wiki/", views.init_wiki, name="init_wiki"), +] diff --git a/eproba/apps/wiki/views.py b/eproba/apps/wiki/views.py new file mode 100644 index 00000000..72dc67d6 --- /dev/null +++ b/eproba/apps/wiki/views.py @@ -0,0 +1,164 @@ +from apps.users.utils import min_function, patrol_required +from django.contrib.auth.decorators import login_required +from django.db.models import Q +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_http_methods + +from .forms import PageForm +from .models import Folder, Page + + +@login_required +def folder(request, folder_id=None): + """ + Render the index view for a given folder or the root level. + """ + if folder_id: + folder_instance = get_object_or_404(Folder, id=folder_id) + children_folders = [ + child + for child in folder_instance.get_children() + if child.can_view(request.user) + ] + folder_pages = [ + page for page in folder_instance.get_pages() if page.can_view(request.user) + ] + context = { + "folders": children_folders, + "pages": folder_pages, + "current_folder": folder_instance, + "allow_edit": folder_instance.can_edit(request.user), + } + return render(request, "wiki/index.html", context) + + # For root-level view, build an access filter once. + access_filter = ( + Q(owner=request.user) + | Q(for_all=True) + | Q(team=request.user.patrol.team) + | Q(patrol=request.user.patrol) + | Q(organization=request.user.patrol.team.organization) + | Q(district=request.user.patrol.team.district) + ) + root_folders = Folder.get_root_nodes().filter(access_filter) + root_pages = Page.objects.filter(folder=None).filter(access_filter) + context = { + "folders": root_folders, + "pages": root_pages, + "allow_wiki_init": request.user.function >= 3 + and not root_folders.filter(team=request.user.patrol.team).exists(), + "allow_edit": request.user.is_staff + and request.user.has_perm("wiki.add_folder"), + } + return render(request, "wiki/index.html", context) + + +@login_required +def page(request, page_id): + """Display a single page.""" + page_instance = get_object_or_404(Page, id=page_id) + return render( + request, + "wiki/page.html", + {"page": page_instance, "allow_edit": page_instance.can_edit(request.user)}, + ) + + +@patrol_required +@min_function(3) +def init_wiki(request): + """Initialize the wiki by creating a root folder for the patrol team if none exists.""" + if Folder.objects.filter(team=request.user.patrol.team).exists(): + return redirect("wiki:index") + new_folder = Folder.add_root( + name=request.user.patrol.team.name, + owner=request.user, + team=request.user.patrol.team, + ) + return redirect("wiki:folder", folder_id=new_folder.id) + + +@login_required +@require_http_methods(["POST"]) +def create_folder(request, folder_id=None): + """ + Create a new folder. + - If a parent folder is specified, create a child folder. + - Otherwise, create a root-level folder (if permitted). + """ + name = request.POST.get("name") + if folder_id: + parent = get_object_or_404(Folder, id=folder_id) + if not parent.can_edit(request.user): + return redirect("wiki:folder", folder_id=folder_id) + if name: + new_folder = parent.add_child(name=name, owner=request.user) + return redirect("wiki:folder", folder_id=new_folder.id) + return redirect("wiki:folder", folder_id=folder_id) + elif request.user.is_staff and request.user.has_perm("wiki.add_folder"): + if name: + new_folder = Folder.add_root(name=name, owner=request.user) + return redirect("wiki:folder", folder_id=new_folder.id) + return redirect("wiki:index") + + +@login_required +def create_page(request, folder_id=None): + """ + Create a new page. + - If a folder is specified, create a page in that folder. + - Otherwise, create a root-level page. + """ + folder_instance = None + if folder_id: + folder_instance = get_object_or_404(Folder, id=folder_id) + if not folder_instance.can_edit(request.user): + return redirect("wiki:folder", folder_id=folder_id) + + if request.method == "POST": + form = PageForm(request.POST) + if form.is_valid(): + new_page = form.save(commit=False) + new_page.owner = request.user + if folder_instance: + new_page.folder = folder_instance + new_page.save() + return redirect("wiki:page", page_id=new_page.id) + else: + form = PageForm() + + context = {"form": form} + if folder_instance: + context["folder"] = folder_instance + return render(request, "wiki/create_page.html", context) + + +def edit_folder(request, folder_id): + """Edit the folder name.""" + folder_instance = get_object_or_404(Folder, id=folder_id) + if not folder_instance.can_edit(request.user): + return redirect("wiki:folder", folder_id=folder_id) + + if request.method == "POST": + name = request.POST.get("name") + if name: + folder_instance.name = name + folder_instance.save() + return redirect("wiki:folder", folder_id=folder_id) + + +def edit_page(request, page_id): + """Edit the page content.""" + page_instance = get_object_or_404(Page, id=page_id) + if not page_instance.can_edit(request.user): + return redirect("wiki:page", page_id=page_id) + + if request.method == "POST": + form = PageForm(request.POST, instance=page_instance) + if form.is_valid(): + form.save() + return redirect("wiki:page", page_id=page_id) + else: + form = PageForm(instance=page_instance) + + return render(request, "wiki/edit_page.html", {"form": form, "page": page_instance}) diff --git a/eproba/apps/worksheets/admin.py b/eproba/apps/worksheets/admin.py index ae683037..386bcffd 100644 --- a/eproba/apps/worksheets/admin.py +++ b/eproba/apps/worksheets/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Task, Worksheet +from .models import Task, TemplateTask, TemplateWorksheet, Worksheet class TaskInline(admin.TabularInline): @@ -10,52 +10,30 @@ class TaskInline(admin.TabularInline): class WorksheetAdmin(admin.ModelAdmin): list_filter = ( - "is_template", - "is_archived", - "user__patrol__team", - ) - - super_list_filter = ( - "is_template", "is_archived", "deleted", "user__patrol__team", ) fieldsets = [ - (None, {"fields": ["user", "supervisor"]}), - (None, {"fields": ["name", "is_archived"]}), - (None, {"fields": ["is_template"]}), - ] - - super_fieldsets = [ (None, {"fields": ["user", "supervisor"]}), (None, {"fields": ["name", "is_archived", "deleted"]}), - (None, {"fields": ["is_template"]}), ] inlines = [TaskInline] - def get_form(self, request, obj=None, **kwargs): - if request.user.is_superuser: - self.fieldsets = self.super_fieldsets - return super(WorksheetAdmin, self).get_form(request, obj, **kwargs) +admin.site.register(Worksheet, WorksheetAdmin) + + +class TemplateTaskInline(admin.TabularInline): + model = TemplateTask + extra = 1 - def get_list_filter(self, request): - if request.user.is_superuser: - return self.super_list_filter - return self.list_filter - def get_queryset(self, request): - qs = super(WorksheetAdmin, self).get_queryset(request) - if not request.user.is_superuser: - if not request.user.patrol: - return qs.filter(user__id=request.user.id) - return qs.filter(user__patrol__team=request.user.patrol.team).exclude( - deleted=True - ) - return qs +class TemplateWorksheetAdmin(admin.ModelAdmin): + list_display = ("name", "team", "organization") + inlines = [TemplateTaskInline] -admin.site.register(Worksheet, WorksheetAdmin) +admin.site.register(TemplateWorksheet, TemplateWorksheetAdmin) diff --git a/eproba/apps/worksheets/api/__init__.py b/eproba/apps/worksheets/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eproba/apps/worksheets/permissions.py b/eproba/apps/worksheets/api/permissions.py similarity index 69% rename from eproba/apps/worksheets/permissions.py rename to eproba/apps/worksheets/api/permissions.py index 7f469981..2497d83a 100644 --- a/eproba/apps/worksheets/permissions.py +++ b/eproba/apps/worksheets/api/permissions.py @@ -44,3 +44,27 @@ def has_object_permission(self, request, view, task): class IsTaskOwner(permissions.BasePermission): def has_object_permission(self, request, view, task): return request.user == task.worksheet.user.user + + +class IsAllowedToReadOrManageTemplateWorksheet(permissions.BasePermission): + def has_object_permission(self, request, view, template_worksheet): + if request.user.function < 2: + return False + + is_team_template = template_worksheet.team == request.user.patrol.team + + is_org_template = ( + template_worksheet.team is None + and template_worksheet.organization == request.user.patrol.team.organization + ) + + if request.method in permissions.SAFE_METHODS: + return is_team_template or is_org_template + + if is_org_template and ( + not request.user.is_staff + or not request.user.has_perm("worksheets.change_templateworksheet") + ): + return False + + return is_team_template or is_org_template diff --git a/eproba/apps/worksheets/serializers.py b/eproba/apps/worksheets/api/serializers.py similarity index 86% rename from eproba/apps/worksheets/serializers.py rename to eproba/apps/worksheets/api/serializers.py index 300fd193..1abc2404 100644 --- a/eproba/apps/worksheets/serializers.py +++ b/eproba/apps/worksheets/api/serializers.py @@ -1,7 +1,6 @@ +from apps.worksheets.models import Task, TemplateTask, TemplateWorksheet, Worksheet from rest_framework import serializers -from .models import Task, Worksheet - class TaskSerializer(serializers.ModelSerializer): class Meta: @@ -26,6 +25,12 @@ def update(self, instance, validated_data): return Task.objects.get(id=instance.id) +class TemplateTaskSerializer(serializers.ModelSerializer): + class Meta: + model = TemplateTask + fields = ["id", "task", "description", "template_notes"] + + class WorksheetSerializer(serializers.ModelSerializer): tasks = TaskSerializer(many=True, required=False) @@ -34,6 +39,7 @@ class Meta: fields = [ "id", "name", + "description", "user", "updated_at", "supervisor", @@ -90,7 +96,7 @@ def update(self, instance, validated_data): if not task_name: continue if task_name in existing_task_names: - # Update only name and description, clear other fields + # Update only the description of existing tasks Task.objects.filter(task=task_name, worksheet=instance).update( description=task_data.get("description", ""), ) @@ -105,3 +111,11 @@ def update(self, instance, validated_data): ) return instance + + +class TemplateWorksheetSerializer(serializers.ModelSerializer): + tasks = TemplateTaskSerializer(many=True, required=False) + + class Meta: + model = TemplateWorksheet + fields = ["id", "name", "description", "template_notes", "tasks"] diff --git a/eproba/apps/worksheets/api/views.py b/eproba/apps/worksheets/api/views.py new file mode 100644 index 00000000..b6483aa3 --- /dev/null +++ b/eproba/apps/worksheets/api/views.py @@ -0,0 +1,188 @@ +from apps.users.models import User +from apps.users.tasks import clear_tokens +from apps.worksheets.models import Task, TemplateWorksheet, Worksheet +from apps.worksheets.tasks import remove_expired_deleted_worksheets +from apps.worksheets.utils import send_notification +from django.db.models import Q +from django.urls import reverse +from django.utils import timezone +from rest_framework import viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import ListAPIView, get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet + +from .permissions import ( + IsAllowedToManageTaskOrReadOnlyForOwner, + IsAllowedToManageWorksheetOrReadOnlyForOwner, + IsAllowedToReadOrManageTemplateWorksheet, + IsTaskOwner, +) +from .serializers import ( + TaskSerializer, + TemplateWorksheetSerializer, + WorksheetSerializer, +) + + +class WorksheetViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated, IsAllowedToManageWorksheetOrReadOnlyForOwner] + serializer_class = WorksheetSerializer + lookup_field = "id" + + def get_queryset(self): + user = self.request.user + last_sync = self.request.query_params.get("last_sync") + qs = Worksheet.objects.all() + if last_sync: + from datetime import datetime + + qs = qs.filter(updated_at__gt=datetime.fromtimestamp(int(last_sync))) + if self.request.query_params.get("user") is not None: + return qs.filter(user=user, is_archived=False) + if self.request.query_params.get("archived") is not None: + return qs.filter(user__patrol__team=user.patrol.team, is_archived=True) + # This is here for backward compatibility + if self.request.query_params.get("templates") is not None: + return TemplateWorksheet.objects.filter( + Q(team=user.patrol.team) + | Q(team=None, organization=user.patrol.team.organization) + ) + # if user.function >= 5: + # return qs.filter(is_archived=False) + # TODO: After updating function levels, change the above line to make more sense + if user.patrol and user.function >= 2: + return qs.filter( + Q(user__patrol__team=user.patrol.team, is_archived=False) + | Q(supervisor=user, is_archived=False) + ) + return qs.filter(user=user, is_archived=False) + + def get_serializer_class(self): + # This is here for backward compatibility + if self.request.query_params.get("templates") is not None: + return TemplateWorksheetSerializer + return WorksheetSerializer + + def perform_destroy(self, instance): + instance.deleted = True + instance.save() + remove_expired_deleted_worksheets() # remove worksheets deleted more than 30 days ago, it's here as a temporary solution + + def perform_create(self, serializer): + if serializer.validated_data.get("user") is None: + serializer.save(user=self.request.user) + elif ( + serializer.validated_data.get("user") != self.request.user + and self.request.user.function < 2 + ): + raise PermissionDenied("You can't create worksheets for other user") + else: + serializer.save() + + +class TaskDetails(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated, IsAllowedToManageTaskOrReadOnlyForOwner] + serializer_class = TaskSerializer + lookup_field = "id" + + def get_queryset(self): + user = self.request.user + qs = Task.objects.filter( + worksheet__id=self.kwargs["worksheet_id"], id=self.kwargs["id"] + ) + if user.function >= 5: + return qs + if user.patrol and user.function >= 2: + return qs.filter( + Q( + worksheet__user__patrol__team=user.patrol.team, + worksheet__is_archived=False, + ) + | Q(worksheet__supervisor=user, worksheet__is_archived=False) + ) + return qs.filter(worksheet__user=user) + + def perform_update(self, serializer): + if serializer.validated_data.get("status") in [0, 2]: + serializer.instance.approval_date = timezone.now() + serializer.instance.approver = self.request.user + serializer.save() + serializer.instance.worksheet.save() + clear_tokens() + + +class TemplateWorksheetViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, IsAllowedToReadOrManageTemplateWorksheet] + serializer_class = TemplateWorksheetSerializer + + def get_queryset(self): + if not self.request.user.patrol: + return TemplateWorksheet.objects.none() + return TemplateWorksheet.objects.filter( + Q(team=self.request.user.patrol.team) + | Q(team=None, organization=self.request.user.patrol.team.organization) + ) + + def perform_create(self, serializer): + # Automatically set the team to the user's team if not provided + if not serializer.validated_data.get("team"): + serializer.save(team=self.request.user.patrol.team) + else: + serializer.save() + + def perform_update(self, serializer): + # Optionally, you might check if the user is allowed to change the team + serializer.save() + + +class TasksToBeChecked(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = WorksheetSerializer + + def get_queryset(self): + return Worksheet.objects.filter( + tasks__approver=self.request.user, tasks__status=1 + ).distinct() + + +class SubmitTask(APIView): + permission_classes = [IsAuthenticated, IsTaskOwner] + + def post(self, request, *args, **kwargs): + if request.data.get("approver") is None: + return Response({"approver": "This field is required."}, status=422) + task = get_object_or_404( + Task, id=kwargs["id"], worksheet__id=kwargs["worksheet_id"] + ) + if task.status == 2: + return Response({"message": "Task already approved"}) + task.status = 1 + task.approver = User.objects.get(id=request.data["approver"]) + task.approval_date = timezone.now() + task.save() + send_notification( + targets=task.approver, + title="Nowe zadanie do sprawdzenia", + body=f"Pojawił się nowy punkt do sprawdzenia dla {task.worksheet.user}", + link=reverse("worksheets:check_tasks"), + ) + return Response({"message": "Task submitted"}) + + +class UnsubmitTask(APIView): + permission_classes = [IsAuthenticated, IsTaskOwner] + + def post(self, request, *args, **kwargs): + task = get_object_or_404( + Task, id=kwargs["id"], worksheet__id=kwargs["worksheet_id"] + ) + if task.status != 1: + return Response({"message": "Task is not submitted"}) + task.status = 0 + task.approver = None + task.approval_date = None + task.save() + return Response({"message": "Task unsubmitted"}) diff --git a/eproba/apps/worksheets/forms.py b/eproba/apps/worksheets/forms.py index ac972d4f..96945731 100644 --- a/eproba/apps/worksheets/forms.py +++ b/eproba/apps/worksheets/forms.py @@ -1,25 +1,32 @@ +from apps.users.models import User +from apps.worksheets.models import Task, TemplateTask, TemplateWorksheet, Worksheet from django import forms from django.db.models import Q from django.forms import ModelForm, Select, Textarea, TextInput -from ..users.models import User -from .models import Task, Worksheet - class WorksheetCreateForm(ModelForm): + def __init__(self, *args, **kwargs): + self.template_notes = kwargs.pop("template_notes", None) + super(WorksheetCreateForm, self).__init__(*args, **kwargs) + class Meta: model = Worksheet - fields = ["name"] + fields = ["name", "description"] labels = { "name": "Nazwa próby", } + widgets = { + "description": Textarea(attrs={"class": "textarea", "rows": 2}), + } class ExtendedWorksheetCreateForm(ModelForm): def __init__(self, user, *args, **kwargs): + self.template_notes = kwargs.pop("template_notes", None) super(ExtendedWorksheetCreateForm, self).__init__(*args, **kwargs) self.fields["user"].required = True - self.initial["user"] = user + self.fields["user"].initial = user if user.patrol.team and user.function < 5: self.fields["user"].queryset = User.objects.filter( patrol__team=user.patrol.team @@ -27,26 +34,74 @@ def __init__(self, user, *args, **kwargs): class Meta: model = Worksheet - fields = ["user", "name"] + fields = ["user", "name", "description"] labels = { "user": "Dla kogo chcesz stworzyć próbę?", "name": "Nazwa próby", } + widgets = { + "description": Textarea(attrs={"class": "textarea", "rows": 2}), + } + + +class TemplateWorksheetCreateForm(ModelForm): + class Meta: + model = TemplateWorksheet + fields = ["name", "description", "template_notes"] + labels = { + "name": "Nazwa szablonu", + "description": "Opis szablonu", + "template_notes": "Notatki (widoczne tylko w szablonie)", + } + widgets = { + "description": Textarea(attrs={"class": "textarea", "rows": 2}), + "template_notes": Textarea(attrs={"class": "textarea", "rows": 2}), + } + + +class ExtendedTemplateWorksheetCreateForm(ModelForm): + for_organization = forms.BooleanField( + label="Dla całej organizacji?", + required=False, + initial=False, + ) + + class Meta: + model = TemplateWorksheet + fields = ["name", "description", "template_notes", "for_organization"] + labels = { + "name": "Nazwa szablonu", + "description": "Opis szablonu", + "template_notes": "Notatki (widoczne tylko w szablonie)", + } + widgets = { + "description": Textarea(attrs={"class": "textarea", "rows": 2}), + "template_notes": Textarea(attrs={"class": "textarea", "rows": 2}), + } class TaskForm(ModelForm): + def __init__(self, *args, **kwargs): + super(TaskForm, self).__init__(*args, **kwargs) + self.template_notes = self.initial.get("template_notes") + class Meta: model = Task fields = ["task", "description"] - labels = { - "task": "Zadanie", - "description": "Opis zadania", + widgets = { + "task": TextInput(attrs={"class": "input"}), + "description": Textarea(attrs={"class": "textarea", "rows": 3}), } + + +class TemplateTaskForm(ModelForm): + class Meta: + model = TemplateTask + fields = ["task", "description", "template_notes"] widgets = { "task": TextInput(attrs={"class": "input"}), - "description": Textarea( - attrs={"class": "textarea tasks-description is-hidden", "rows": 3} - ), + "description": Textarea(attrs={"class": "textarea", "rows": 3}), + "template_notes": Textarea(attrs={"class": "textarea", "rows": 3}), } diff --git a/eproba/apps/worksheets/migrations/0002_worksheet_created_at_worksheet_description_and_more.py b/eproba/apps/worksheets/migrations/0002_worksheet_created_at_worksheet_description_and_more.py new file mode 100644 index 00000000..2b013c06 --- /dev/null +++ b/eproba/apps/worksheets/migrations/0002_worksheet_created_at_worksheet_description_and_more.py @@ -0,0 +1,130 @@ +# Generated by Django 5.1.6 on 2025-02-27 18:47 + +import uuid + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("teams", "0006_team_organization"), + ("worksheets", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="worksheet", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="worksheet", + name="description", + field=models.TextField(blank=True, default="", verbose_name="Opis próby"), + ), + migrations.CreateModel( + name="TemplateWorksheet", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "organization", + models.IntegerField( + blank=True, + choices=[ + (0, "Organizacja Harcerzy"), + (1, "Organizacja Harcerek"), + ], + null=True, + ), + ), + ( + "name", + models.CharField(max_length=200, verbose_name="Nazwa szablonu"), + ), + ( + "description", + models.TextField( + blank=True, default="", verbose_name="Opis szablonu" + ), + ), + ( + "template_notes", + models.TextField( + blank=True, default="", verbose_name="Notatki do szablonu" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="templates", + to="teams.team", + ), + ), + ], + options={ + "verbose_name": "Szablon", + "verbose_name_plural": "Szablony", + }, + ), + migrations.CreateModel( + name="TemplateTask", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "task", + models.CharField(max_length=250, verbose_name="Zadanie szablonu"), + ), + ( + "description", + models.TextField( + blank=True, default="", verbose_name="Opis zadania szablonu" + ), + ), + ( + "template_notes", + models.TextField( + blank=True, + default="", + verbose_name="Notatki do zadania szablonu", + ), + ), + ( + "template", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tasks", + to="worksheets.templateworksheet", + ), + ), + ], + options={ + "verbose_name": "Zadanie szablonu", + "verbose_name_plural": "Zadania szablonu", + }, + ), + ] diff --git a/eproba/apps/worksheets/migrations/0003_migrate_templates.py b/eproba/apps/worksheets/migrations/0003_migrate_templates.py new file mode 100644 index 00000000..18ccb742 --- /dev/null +++ b/eproba/apps/worksheets/migrations/0003_migrate_templates.py @@ -0,0 +1,57 @@ +# Generated by Django 5.1.6 on 2025-02-27 18:47 + + +from django.db import migrations + + +def migrate_templates(apps, schema_editor): + # Get historical versions of the models + Worksheet = apps.get_model("worksheets", "Worksheet") + Task = apps.get_model("worksheets", "Task") + TemplateWorksheet = apps.get_model("worksheets", "TemplateWorksheet") + TemplateTask = apps.get_model("worksheets", "TemplateTask") + + # For each Worksheet that was marked as a template, create a new Template instance and remove the old Worksheet. + for worksheet in Worksheet.objects.filter(is_template=True, deleted=False): + # Determine the team. If your User model has a team attribute, use it; + # otherwise, use the first available Team (or adjust as needed). + if hasattr(worksheet.user, "patrol") and worksheet.user.patrol: + team_instance = worksheet.user.patrol.team + else: + continue # Skip this worksheet if the user doesn’t have a team + + new_template = TemplateWorksheet.objects.create( + id=worksheet.id, + team=team_instance, + name=worksheet.name, + description="", # Old templates didn’t have a description; default to empty + created_at=worksheet.updated_at, + # Use the updated_at as the creation time as created_at didn’t exist before + updated_at=worksheet.updated_at, + ) + + # Now, migrate all tasks related to the worksheet into TemplateTask records. + tasks = Task.objects.filter(worksheet=worksheet) + for task in tasks: + TemplateTask.objects.create( + id=task.id, + template=new_template, + task=task.task, + description=task.description, + ) + + # Remove all migrated Template and TemplateTask records. + Worksheet.objects.filter(is_template=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("worksheets", "0002_worksheet_created_at_worksheet_description_and_more"), + ] + + operations = [ + migrations.RunPython( + code=migrate_templates, + ), + ] diff --git a/eproba/apps/worksheets/migrations/0004_remove_worksheet_is_template_and_more.py b/eproba/apps/worksheets/migrations/0004_remove_worksheet_is_template_and_more.py new file mode 100644 index 00000000..0eed0889 --- /dev/null +++ b/eproba/apps/worksheets/migrations/0004_remove_worksheet_is_template_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.6 on 2025-02-27 18:47 + + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("worksheets", "0003_migrate_templates"), + ] + + operations = [ + migrations.RemoveField( + model_name="worksheet", + name="is_template", + ), + migrations.AlterField( + model_name="templateworksheet", + name="team", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="templates", + to="teams.team", + ), + ), + ] diff --git a/eproba/apps/worksheets/models.py b/eproba/apps/worksheets/models.py index 7d584ec9..facfa324 100644 --- a/eproba/apps/worksheets/models.py +++ b/eproba/apps/worksheets/models.py @@ -1,5 +1,6 @@ import uuid +from apps.teams.models import OrganizationChoice, Team from apps.users.models import User from django.db import models from django.db.models import UUIDField @@ -31,11 +32,12 @@ class Worksheet(models.Model): verbose_name="Opiekun próby", ) name = models.CharField(max_length=200, verbose_name="Nazwa próby") + description = models.TextField(blank=True, default="", verbose_name="Opis próby") is_archived = models.BooleanField( default=False, verbose_name="Próba zarchiwizowana?" ) - is_template = models.BooleanField(default=False, verbose_name="Szablon?") deleted = models.BooleanField(default=False, verbose_name="Usunięta?") + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): @@ -77,3 +79,48 @@ class Meta: def save(self, *args, **kwargs): super(Task, self).save() self.worksheet.save() # update updated_at + + +class TemplateWorksheet(models.Model): + id = UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + team = models.ForeignKey( + Team, related_name="templates", on_delete=models.CASCADE, null=True, blank=True + ) + organization = models.IntegerField( + choices=OrganizationChoice.choices, null=True, blank=True + ) + name = models.CharField(max_length=200, verbose_name="Nazwa szablonu") + description = models.TextField(blank=True, default="", verbose_name="Opis szablonu") + template_notes = models.TextField( + blank=True, default="", verbose_name="Notatki do szablonu" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Szablon" + verbose_name_plural = "Szablony" + + +class TemplateTask(models.Model): + id = UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + template = models.ForeignKey( + TemplateWorksheet, related_name="tasks", on_delete=models.CASCADE + ) + task = models.CharField(max_length=250, verbose_name="Zadanie szablonu") + description = models.TextField( + blank=True, default="", verbose_name="Opis zadania szablonu" + ) + template_notes = models.TextField( + blank=True, default="", verbose_name="Notatki do zadania szablonu" + ) + + def __str__(self): + return self.task + + class Meta: + verbose_name = "Zadanie szablonu" + verbose_name_plural = "Zadania szablonu" diff --git a/eproba/apps/worksheets/templates/worksheets/archive.html b/eproba/apps/worksheets/templates/worksheets/archive.html index 1155ce32..275cea97 100644 --- a/eproba/apps/worksheets/templates/worksheets/archive.html +++ b/eproba/apps/worksheets/templates/worksheets/archive.html @@ -43,20 +43,22 @@
-
- {{ worksheet }} -
- - - +
+ {{ worksheet }} +
+
+ + {% if user.function > 2 %} - Opiekun próby: {{ worksheet.supervisor.user.full_name_nickname }} {% endif %} + {% if worksheet.description %} +

{{ worksheet.description }}

+ {% endif %}


diff --git a/eproba/apps/worksheets/templates/worksheets/create_template.html b/eproba/apps/worksheets/templates/worksheets/create_template.html index be031a5b..fc6985ef 100644 --- a/eproba/apps/worksheets/templates/worksheets/create_template.html +++ b/eproba/apps/worksheets/templates/worksheets/create_template.html @@ -13,34 +13,65 @@ {% csrf_token %} {{ worksheet|crispy }} {{ tasks.management_form }} -

Zadania:

+
+
+
+

Zadania:

+
+
+
+
+ +
+
+
{% for form in tasks %} -
-
{{ forloop.counter }}. -
-
-
- {{ form.task }} +
+
+
{{ forloop.counter }}. +
+
+
+ {{ form.task }} +
+
+ + +
+
+ +
-
- - +
+ diff --git a/eproba/apps/worksheets/templates/worksheets/create_worksheet.html b/eproba/apps/worksheets/templates/worksheets/create_worksheet.html index f4ab722b..ed99d9bd 100644 --- a/eproba/apps/worksheets/templates/worksheets/create_worksheet.html +++ b/eproba/apps/worksheets/templates/worksheets/create_worksheet.html @@ -12,6 +12,9 @@
{% csrf_token %} {{ worksheet|crispy }} + {% if worksheet.template_notes %} + {{ worksheet.template_notes }} + {% endif %} {{ tasks.management_form }}
@@ -22,7 +25,7 @@

Zadania:

-
+ {% if form.template_notes %} + {{ form.template_notes }} + {% endif %} + -
+
- {% if worksheet.show_description_column %} - - {% endif %} - {% if worksheet.show_description_column %} - - {% endif %} - {% for task in worksheet.tasks.all %} + {% for task in worksheet_template.tasks.all %} - - {% if worksheet.show_description_column %} - {% if task.description != "" %} - - {% else %} - + {% endfor %}
Lp. ZadanieOpis
{{ forloop.counter }}{{ task.task }}{{ task.description }} +

{{ task.task }}

+ {% if task.description %} +

{{ task.description }}

+ {% endif %} + {% if task.template_notes %} +

{{ task.template_notes }}

{% endif %} - {% endif %} +
{% endif %}
-
- + Utwórz nowy szablon + +
{% else %}
diff --git a/eproba/apps/worksheets/templates/worksheets/worksheet.html b/eproba/apps/worksheets/templates/worksheets/worksheet.html index 49f4ec5c..5654ec0f 100644 --- a/eproba/apps/worksheets/templates/worksheets/worksheet.html +++ b/eproba/apps/worksheets/templates/worksheets/worksheet.html @@ -18,6 +18,9 @@

Opiekun próby: {{ worksheet.supervisor.full_name_nickname }}

{% endif %} + {% if worksheet.description %} +

{{ worksheet.description }}

+ {% endif %} @@ -73,7 +76,8 @@

Opiekun próby: {% if not is_shared or user == worksheet.user %}