mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 11:03:04 +00:00 
			
		
		
		
	Compare commits
	
		
			168 Commits
		
	
	
		
			windows-up
			...
			openapi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 106dc32a3d | |||
| 05edf33062 | |||
| 
						 | 
					98175e397c | ||
| 
						 | 
					62246f342d | ||
| 
						 | 
					bff6513192 | ||
| 
						 | 
					bf0779a096 | ||
| 
						 | 
					9991507297 | ||
| 
						 | 
					222ff762da | ||
| 6b27a97e7b | |||
| 
						 | 
					87f790a044 | ||
| 75c4c55a32 | |||
| 728ad157e9 | |||
| e542fe11b9 | |||
| aa66fc61ab | |||
| 7f8304e407 | |||
| 3b80b36ed6 | |||
| ba6e2a6402 | |||
| 6841d96455 | |||
| 8528820d89 | |||
| 
						 | 
					2c9b72fe1d | ||
| fe417b0c29 | |||
| 
						 | 
					b3f67657d7 | ||
| 
						 | 
					602c57c001 | ||
| 
						 | 
					6a17e4480e | ||
| 
						 | 
					1f1cd2ce0f | ||
| a653f98fc1 | |||
| a01ea13f5b | |||
| 10701ccdfa | |||
| 07028c8dd8 | |||
| 4890fcf0e1 | |||
| 2e71275f5b | |||
| 
						 | 
					be87af5e06 | ||
| f9c36c8f99 | |||
| 92d282f4ba | |||
| a1bf86dabf | |||
| 
						 | 
					21284546c4 | ||
| 
						 | 
					e936f0d285 | ||
| 
						 | 
					6af03240a1 | ||
| 
						 | 
					01c92feb40 | ||
| 
						 | 
					94d2c5660a | ||
| 
						 | 
					71b3588577 | ||
| 
						 | 
					2def57d82c | ||
| 
						 | 
					0e88260c31 | ||
| 
						 | 
					86c2ea7fd9 | ||
| 
						 | 
					fc3b82c35c | ||
| 
						 | 
					1d177412c3 | ||
| 
						 | 
					c272cad2ea | ||
| 
						 | 
					e757fb43a1 | ||
| 
						 | 
					8705fbe4b2 | ||
| 
						 | 
					aa60462653 | ||
| 
						 | 
					9c0d89de83 | ||
| 
						 | 
					809febc353 | ||
| f4ff247862 | |||
| 1978658b9c | |||
| 219700f0bc | |||
| 2918048b16 | |||
| a87016a23f | |||
| f7ff77b88f | |||
| e8db68b960 | |||
| 93a5c3a02a | |||
| e46cba7a06 | |||
| 
						 | 
					ba21738bd9 | ||
| 
						 | 
					b1db52d2b6 | ||
| 
						 | 
					2bed89aaba | ||
| 
						 | 
					86c68eeb32 | ||
| 
						 | 
					8cb53ceba2 | ||
| 
						 | 
					a96b374ad7 | ||
| 
						 | 
					9945993f0b | ||
| 
						 | 
					59e90ec754 | ||
| 
						 | 
					41bff53853 | ||
| 
						 | 
					88b3f7c322 | ||
| 
						 | 
					b31445fefb | ||
| 
						 | 
					2dc32f8b20 | ||
| 
						 | 
					b43b531c3b | ||
| 
						 | 
					bf388e68f0 | ||
| 
						 | 
					5252d450a9 | ||
| 
						 | 
					8f17c3d830 | ||
| 
						 | 
					6627ea417c | ||
| 
						 | 
					92b2befd55 | ||
| 
						 | 
					43207455b8 | ||
| 
						 | 
					5fa431e29b | ||
| 
						 | 
					78f3caa455 | ||
| 
						 | 
					6d519e3a07 | ||
| 
						 | 
					85c8b7d11c | ||
| 
						 | 
					fa02f4b5f0 | ||
| 
						 | 
					3df33261ce | ||
| 
						 | 
					ee1bcf2011 | ||
| 
						 | 
					571b3a4e02 | ||
| 
						 | 
					6bf02cecd9 | ||
| 
						 | 
					05d4a09f8c | ||
| 
						 | 
					169e9ea55a | ||
| 9b916f6204 | |||
| 
						 | 
					2123e83010 | ||
| 
						 | 
					294b59b4d6 | ||
| 
						 | 
					820ceb48dd | ||
| 
						 | 
					73ce681307 | ||
| 
						 | 
					faa757b54f | ||
| 
						 | 
					36076aefcc | ||
| 
						 | 
					b9482a6f08 | ||
| d573182f4b | |||
| 
						 | 
					75be6454eb | ||
| 
						 | 
					428fe68cdb | ||
| 
						 | 
					18967cf3d6 | ||
| 
						 | 
					14ed43aaa5 | ||
| 
						 | 
					c555d5c78c | ||
| 5db9819560 | |||
| dd2cd0a18d | |||
| 
						 | 
					c7ae70972f | ||
| 
						 | 
					17cf0c67b3 | ||
| 7d40387f43 | |||
| 
						 | 
					20a535429c | ||
| 
						 | 
					5ff7bb3259 | ||
| 
						 | 
					0d95c3b9c9 | ||
| 
						 | 
					170f9dde61 | ||
| 
						 | 
					61170c0918 | ||
| 
						 | 
					80940765fe | ||
| 
						 | 
					9d98a20e40 | ||
| 6f9f1ac1e7 | |||
| 
						 | 
					71b096f9ef | ||
| 
						 | 
					9272f53bea | ||
| 
						 | 
					9b5f08e13c | ||
| 
						 | 
					d0b1a49300 | ||
| 
						 | 
					e500cf92ee | ||
| 
						 | 
					551091f650 | ||
| 
						 | 
					0c01ad1770 | ||
| 
						 | 
					cba915c34d | ||
| 
						 | 
					7ac41ac5cb | ||
| 
						 | 
					c6bb509fc3 | ||
| 
						 | 
					4d0d7adce1 | ||
| 
						 | 
					6bcc420af1 | ||
| 
						 | 
					9f35f5356b | ||
| 
						 | 
					8d73ec797b | ||
| 
						 | 
					c3fc8538cc | ||
| 
						 | 
					600657b1a8 | ||
| 
						 | 
					d3f21c8f16 | ||
| 
						 | 
					895d51586e | ||
| 
						 | 
					e200f28267 | ||
| a4c6439981 | |||
| 
						 | 
					6ee2e8c5da | ||
| f4af29acb4 | |||
| 
						 | 
					a8810816f0 | ||
| 
						 | 
					b7bf3fd375 | ||
| 
						 | 
					b26e85ebb2 | ||
| 
						 | 
					8b8a295e16 | ||
| 
						 | 
					894690a97f | ||
| 
						 | 
					843ce2e3a7 | ||
| 9f33ddd883 | |||
| a2dc4f1964 | |||
| 
						 | 
					cca486f2b9 | ||
| 
						 | 
					b9e27ef191 | ||
| 29e875bcde | |||
| 
						 | 
					686d67410a | ||
| 
						 | 
					4226ba88ae | ||
| 672bc91e36 | |||
| 2db3290bed | |||
| 
						 | 
					16de128fdb | ||
| 
						 | 
					0d3fd954a3 | ||
| 
						 | 
					673c427485 | ||
| 
						 | 
					b773a05bb5 | ||
| 
						 | 
					c1be55a719 | ||
| 
						 | 
					35c5f96672 | ||
| 
						 | 
					0a5ddcea68 | ||
| 
						 | 
					e6f25fb707 | ||
| 
						 | 
					19e21c80df | ||
| 
						 | 
					cbcdc6171f | ||
| 
						 | 
					444a2936e2 | ||
| 
						 | 
					6a31f38ceb | ||
| 
						 | 
					819cd257a8 | 
							
								
								
									
										17
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					HTTPS=off
 | 
				
			||||||
 | 
					SITH_DEBUG=true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# This is not the real key used in prod
 | 
				
			||||||
 | 
					SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# comment the sqlite line and uncomment the postgres one to switch the dbms
 | 
				
			||||||
 | 
					DATABASE_URL=sqlite:///db.sqlite3
 | 
				
			||||||
 | 
					#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					REDIS_PORT=7963
 | 
				
			||||||
 | 
					CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Used to select which other services to run alongside
 | 
				
			||||||
 | 
					# manage.py, pytest and runserver
 | 
				
			||||||
 | 
					PROCFILE_STATIC=Procfile.static
 | 
				
			||||||
 | 
					PROCFILE_SERVICE=Procfile.service
 | 
				
			||||||
							
								
								
									
										7
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							@@ -4,11 +4,16 @@ runs:
 | 
				
			|||||||
  using: composite
 | 
					  using: composite
 | 
				
			||||||
  steps:
 | 
					  steps:
 | 
				
			||||||
    - name: Install apt packages
 | 
					    - name: Install apt packages
 | 
				
			||||||
      uses: awalsh128/cache-apt-pkgs-action@latest
 | 
					      uses: awalsh128/cache-apt-pkgs-action@v1.4.3
 | 
				
			||||||
      with:
 | 
					      with:
 | 
				
			||||||
        packages: gettext
 | 
					        packages: gettext
 | 
				
			||||||
        version: 1.0  # increment to reset cache
 | 
					        version: 1.0  # increment to reset cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - name: Install Redis
 | 
				
			||||||
 | 
					      uses: shogo82148/actions-setup-redis@v1
 | 
				
			||||||
 | 
					      with:
 | 
				
			||||||
 | 
					        redis-version: "7.x"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    - name: Install uv
 | 
					    - name: Install uv
 | 
				
			||||||
      uses: astral-sh/setup-uv@v5
 | 
					      uses: astral-sh/setup-uv@v5
 | 
				
			||||||
      with:
 | 
					      with:
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										9
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@@ -7,6 +7,11 @@ on:
 | 
				
			|||||||
    branches: [master, taiste]
 | 
					    branches: [master, taiste]
 | 
				
			||||||
  workflow_dispatch:
 | 
					  workflow_dispatch:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					env:
 | 
				
			||||||
 | 
					  SECRET_KEY: notTheRealOne
 | 
				
			||||||
 | 
					  DATABASE_URL: sqlite:///db.sqlite3
 | 
				
			||||||
 | 
					  CACHE_URL: redis://127.0.0.1:6379/0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  pre-commit:
 | 
					  pre-commit:
 | 
				
			||||||
    name: Launch pre-commits checks (ruff)
 | 
					    name: Launch pre-commits checks (ruff)
 | 
				
			||||||
@@ -41,7 +46,7 @@ jobs:
 | 
				
			|||||||
          uv run coverage report
 | 
					          uv run coverage report
 | 
				
			||||||
          uv run coverage html
 | 
					          uv run coverage html
 | 
				
			||||||
      - name: Archive code coverage results
 | 
					      - name: Archive code coverage results
 | 
				
			||||||
        uses: actions/upload-artifact@v3
 | 
					        uses: actions/upload-artifact@v4
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          name: coverage-report
 | 
					          name: coverage-report-${{ matrix.pytest-mark }}
 | 
				
			||||||
          path: coverage_report
 | 
					          path: coverage_report
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -18,6 +18,14 @@ sith/search_indexes/
 | 
				
			|||||||
.coverage
 | 
					.coverage
 | 
				
			||||||
coverage_report/
 | 
					coverage_report/
 | 
				
			||||||
node_modules/
 | 
					node_modules/
 | 
				
			||||||
 | 
					.env
 | 
				
			||||||
 | 
					*.pid
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# compiled documentation
 | 
					# compiled documentation
 | 
				
			||||||
site/
 | 
					site/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Redis ###
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Ignore redis binary dump (dump.rdb) files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					*.rdb
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,7 @@ repos:
 | 
				
			|||||||
    rev: "v0.1.0"  # Use the sha / tag you want to point at
 | 
					    rev: "v0.1.0"  # Use the sha / tag you want to point at
 | 
				
			||||||
    hooks:
 | 
					    hooks:
 | 
				
			||||||
      - id: biome-check
 | 
					      - id: biome-check
 | 
				
			||||||
        additional_dependencies: ["@biomejs/biome@1.9.3"]
 | 
					        additional_dependencies: ["@biomejs/biome@1.9.4"]
 | 
				
			||||||
  - repo: https://github.com/rtts/djhtml
 | 
					  - repo: https://github.com/rtts/djhtml
 | 
				
			||||||
    rev: 3.0.7
 | 
					    rev: 3.0.7
 | 
				
			||||||
    hooks:
 | 
					    hooks:
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										1
									
								
								Procfile.service
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								Procfile.service
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					redis: redis-server --port $REDIS_PORT
 | 
				
			||||||
							
								
								
									
										1
									
								
								Procfile.static
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								Procfile.static
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					bundler: npm run serve
 | 
				
			||||||
@@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from accounting.models import ClubAccount, Company
 | 
					from accounting.models import ClubAccount, Company
 | 
				
			||||||
from accounting.schemas import ClubAccountSchema, CompanySchema
 | 
					from accounting.schemas import ClubAccountSchema, CompanySchema
 | 
				
			||||||
from core.api_permissions import CanAccessLookup
 | 
					from core.auth.api_permissions import CanAccessLookup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@api_controller("/lookup", permissions=[CanAccessLookup])
 | 
					@api_controller("/lookup", permissions=[CanAccessLookup])
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,6 +17,7 @@ import collections
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from django import forms
 | 
					from django import forms
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
				
			||||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
					from django.core.exceptions import PermissionDenied, ValidationError
 | 
				
			||||||
from django.db import transaction
 | 
					from django.db import transaction
 | 
				
			||||||
from django.db.models import Sum
 | 
					from django.db.models import Sum
 | 
				
			||||||
@@ -44,15 +45,15 @@ from accounting.widgets.select import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
from club.models import Club
 | 
					from club.models import Club
 | 
				
			||||||
from club.widgets.select import AutoCompleteSelectClub
 | 
					from club.widgets.select import AutoCompleteSelectClub
 | 
				
			||||||
from core.models import User
 | 
					from core.auth.mixins import (
 | 
				
			||||||
from core.views import (
 | 
					 | 
				
			||||||
    CanCreateMixin,
 | 
					    CanCreateMixin,
 | 
				
			||||||
    CanEditMixin,
 | 
					    CanEditMixin,
 | 
				
			||||||
    CanEditPropMixin,
 | 
					    CanEditPropMixin,
 | 
				
			||||||
    CanViewMixin,
 | 
					    CanViewMixin,
 | 
				
			||||||
    TabedViewMixin,
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from core.models import User
 | 
				
			||||||
from core.views.forms import SelectDate, SelectFile
 | 
					from core.views.forms import SelectDate, SelectFile
 | 
				
			||||||
 | 
					from core.views.mixins import TabedViewMixin
 | 
				
			||||||
from core.views.widgets.select import AutoCompleteSelectUser
 | 
					from core.views.widgets.select import AutoCompleteSelectUser
 | 
				
			||||||
from counter.models import Counter, Product, Selling
 | 
					from counter.models import Counter, Product, Selling
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -86,12 +87,13 @@ class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
 | 
				
			|||||||
    template_name = "core/edit.jinja"
 | 
					    template_name = "core/edit.jinja"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView):
 | 
					class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView):
 | 
				
			||||||
    """Create an accounting type (for the admins)."""
 | 
					    """Create an accounting type (for the admins)."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = SimplifiedAccountingType
 | 
					    model = SimplifiedAccountingType
 | 
				
			||||||
    fields = ["label", "accounting_type"]
 | 
					    fields = ["label", "accounting_type"]
 | 
				
			||||||
    template_name = "core/create.jinja"
 | 
					    template_name = "core/create.jinja"
 | 
				
			||||||
 | 
					    permission_required = "accounting.add_simplifiedaccountingtype"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Accounting types
 | 
					# Accounting types
 | 
				
			||||||
@@ -113,12 +115,13 @@ class AccountingTypeEditView(CanViewMixin, UpdateView):
 | 
				
			|||||||
    template_name = "core/edit.jinja"
 | 
					    template_name = "core/edit.jinja"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccountingTypeCreateView(CanCreateMixin, CreateView):
 | 
					class AccountingTypeCreateView(PermissionRequiredMixin, CreateView):
 | 
				
			||||||
    """Create an accounting type (for the admins)."""
 | 
					    """Create an accounting type (for the admins)."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = AccountingType
 | 
					    model = AccountingType
 | 
				
			||||||
    fields = ["code", "label", "movement_type"]
 | 
					    fields = ["code", "label", "movement_type"]
 | 
				
			||||||
    template_name = "core/create.jinja"
 | 
					    template_name = "core/create.jinja"
 | 
				
			||||||
 | 
					    permission_required = "accounting.add_accountingtype"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# BankAccount views
 | 
					# BankAccount views
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from club.models import Club
 | 
					from club.models import Club
 | 
				
			||||||
from club.schemas import ClubSchema
 | 
					from club.schemas import ClubSchema
 | 
				
			||||||
from core.api_permissions import CanAccessLookup
 | 
					from core.auth.api_permissions import CanAccessLookup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@api_controller("/club")
 | 
					@api_controller("/club")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,3 +7,17 @@ class ClubSchema(ModelSchema):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = Club
 | 
					        model = Club
 | 
				
			||||||
        fields = ["id", "name"]
 | 
					        fields = ["id", "name"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ClubProfileSchema(ModelSchema):
 | 
				
			||||||
 | 
					    """The infos needed to display a simple club profile."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = Club
 | 
				
			||||||
 | 
					        fields = ["id", "name", "logo"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    url: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def resolve_url(obj: Club) -> str:
 | 
				
			||||||
 | 
					        return obj.get_absolute_url()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -213,9 +213,9 @@ class TestMembershipQuerySet(TestClub):
 | 
				
			|||||||
            memberships[1].club.members_group,
 | 
					            memberships[1].club.members_group,
 | 
				
			||||||
            memberships[1].club.board_group,
 | 
					            memberships[1].club.board_group,
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        assert set(user.groups.all()) == club_groups
 | 
					        assert set(user.groups.all()).issuperset(club_groups)
 | 
				
			||||||
        user.memberships.all().delete()
 | 
					        user.memberships.all().delete()
 | 
				
			||||||
        assert user.groups.all().count() == 0
 | 
					        assert set(user.groups.all()).isdisjoint(club_groups)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestClubModel(TestClub):
 | 
					class TestClubModel(TestClub):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,6 +25,7 @@
 | 
				
			|||||||
import csv
 | 
					import csv
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
				
			||||||
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
 | 
					from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
 | 
				
			||||||
from django.core.paginator import InvalidPage, Paginator
 | 
					from django.core.paginator import InvalidPage, Paginator
 | 
				
			||||||
from django.db.models import Sum
 | 
					from django.db.models import Sum
 | 
				
			||||||
@@ -49,17 +50,15 @@ from com.views import (
 | 
				
			|||||||
    PosterEditBaseView,
 | 
					    PosterEditBaseView,
 | 
				
			||||||
    PosterListBaseView,
 | 
					    PosterListBaseView,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from core.models import PageRev
 | 
					from core.auth.mixins import (
 | 
				
			||||||
from core.views import (
 | 
					 | 
				
			||||||
    CanCreateMixin,
 | 
					    CanCreateMixin,
 | 
				
			||||||
    CanEditMixin,
 | 
					    CanEditMixin,
 | 
				
			||||||
    CanEditPropMixin,
 | 
					    CanEditPropMixin,
 | 
				
			||||||
    CanViewMixin,
 | 
					    CanViewMixin,
 | 
				
			||||||
    DetailFormView,
 | 
					 | 
				
			||||||
    PageEditViewBase,
 | 
					 | 
				
			||||||
    TabedViewMixin,
 | 
					 | 
				
			||||||
    UserIsRootMixin,
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from core.models import PageRev
 | 
				
			||||||
 | 
					from core.views import DetailFormView, PageEditViewBase
 | 
				
			||||||
 | 
					from core.views.mixins import TabedViewMixin
 | 
				
			||||||
from counter.models import Selling
 | 
					from counter.models import Selling
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -257,7 +256,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
 | 
				
			|||||||
    def get_form_kwargs(self):
 | 
					    def get_form_kwargs(self):
 | 
				
			||||||
        kwargs = super().get_form_kwargs()
 | 
					        kwargs = super().get_form_kwargs()
 | 
				
			||||||
        kwargs["request_user"] = self.request.user
 | 
					        kwargs["request_user"] = self.request.user
 | 
				
			||||||
        kwargs["club"] = self.get_object()
 | 
					        kwargs["club"] = self.object
 | 
				
			||||||
        kwargs["club_members"] = self.members
 | 
					        kwargs["club_members"] = self.members
 | 
				
			||||||
        return kwargs
 | 
					        return kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -274,9 +273,9 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
 | 
				
			|||||||
        users = data.pop("users", [])
 | 
					        users = data.pop("users", [])
 | 
				
			||||||
        users_old = data.pop("users_old", [])
 | 
					        users_old = data.pop("users_old", [])
 | 
				
			||||||
        for user in users:
 | 
					        for user in users:
 | 
				
			||||||
            Membership(club=self.get_object(), user=user, **data).save()
 | 
					            Membership(club=self.object, user=user, **data).save()
 | 
				
			||||||
        for user in users_old:
 | 
					        for user in users_old:
 | 
				
			||||||
            membership = self.get_object().get_membership_for(user)
 | 
					            membership = self.object.get_membership_for(user)
 | 
				
			||||||
            membership.end_date = timezone.now()
 | 
					            membership.end_date = timezone.now()
 | 
				
			||||||
            membership.save()
 | 
					            membership.save()
 | 
				
			||||||
        return resp
 | 
					        return resp
 | 
				
			||||||
@@ -286,9 +285,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
 | 
				
			|||||||
        return super().dispatch(request, *args, **kwargs)
 | 
					        return super().dispatch(request, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_success_url(self, **kwargs):
 | 
					    def get_success_url(self, **kwargs):
 | 
				
			||||||
        return reverse_lazy(
 | 
					        return reverse_lazy("club:club_members", kwargs={"club_id": self.object.id})
 | 
				
			||||||
            "club:club_members", kwargs={"club_id": self.get_object().id}
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
 | 
					class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
 | 
				
			||||||
@@ -474,13 +471,14 @@ class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView):
 | 
				
			|||||||
    current_tab = "props"
 | 
					    current_tab = "props"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ClubCreateView(CanCreateMixin, CreateView):
 | 
					class ClubCreateView(PermissionRequiredMixin, CreateView):
 | 
				
			||||||
    """Create a club (for the Sith admin)."""
 | 
					    """Create a club (for the Sith admin)."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Club
 | 
					    model = Club
 | 
				
			||||||
    pk_url_kwarg = "club_id"
 | 
					    pk_url_kwarg = "club_id"
 | 
				
			||||||
    fields = ["name", "unix_name", "parent"]
 | 
					    fields = ["name", "unix_name", "parent"]
 | 
				
			||||||
    template_name = "core/edit.jinja"
 | 
					    template_name = "core/edit.jinja"
 | 
				
			||||||
 | 
					    permission_required = "club.add_club"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MembershipSetOldView(CanEditMixin, DetailView):
 | 
					class MembershipSetOldView(CanEditMixin, DetailView):
 | 
				
			||||||
@@ -512,12 +510,13 @@ class MembershipSetOldView(CanEditMixin, DetailView):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MembershipDeleteView(UserIsRootMixin, DeleteView):
 | 
					class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
 | 
				
			||||||
    """Delete a membership (for admins only)."""
 | 
					    """Delete a membership (for admins only)."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Membership
 | 
					    model = Membership
 | 
				
			||||||
    pk_url_kwarg = "membership_id"
 | 
					    pk_url_kwarg = "membership_id"
 | 
				
			||||||
    template_name = "core/delete_confirm.jinja"
 | 
					    template_name = "core/delete_confirm.jinja"
 | 
				
			||||||
 | 
					    permission_required = "club.delete_membership"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_success_url(self):
 | 
					    def get_success_url(self):
 | 
				
			||||||
        return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})
 | 
					        return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										12
									
								
								com/admin.py
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								com/admin.py
									
									
									
									
									
								
							@@ -13,17 +13,25 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
from django.contrib import admin
 | 
					from django.contrib import admin
 | 
				
			||||||
 | 
					from django.contrib.admin import TabularInline
 | 
				
			||||||
from haystack.admin import SearchModelAdmin
 | 
					from haystack.admin import SearchModelAdmin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from com.models import News, Poster, Screen, Sith, Weekmail
 | 
					from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsDateInline(TabularInline):
 | 
				
			||||||
 | 
					    model = NewsDate
 | 
				
			||||||
 | 
					    extra = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@admin.register(News)
 | 
					@admin.register(News)
 | 
				
			||||||
class NewsAdmin(SearchModelAdmin):
 | 
					class NewsAdmin(SearchModelAdmin):
 | 
				
			||||||
    list_display = ("title", "type", "club", "author")
 | 
					    list_display = ("title", "club", "author")
 | 
				
			||||||
    search_fields = ("title", "summary", "content")
 | 
					    search_fields = ("title", "summary", "content")
 | 
				
			||||||
    autocomplete_fields = ("author", "moderator")
 | 
					    autocomplete_fields = ("author", "moderator")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    inlines = [NewsDateInline]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@admin.register(Poster)
 | 
					@admin.register(Poster)
 | 
				
			||||||
class PosterAdmin(SearchModelAdmin):
 | 
					class PosterAdmin(SearchModelAdmin):
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										78
									
								
								com/api.py
									
									
									
									
									
								
							
							
						
						
									
										78
									
								
								com/api.py
									
									
									
									
									
								
							@@ -1,10 +1,18 @@
 | 
				
			|||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from typing import Literal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.http import Http404
 | 
					from django.http import Http404, HttpResponse
 | 
				
			||||||
from ninja_extra import ControllerBase, api_controller, route
 | 
					from ninja import Query
 | 
				
			||||||
 | 
					from ninja_extra import ControllerBase, api_controller, paginate, route
 | 
				
			||||||
 | 
					from ninja_extra.pagination import PageNumberPaginationExtra
 | 
				
			||||||
 | 
					from ninja_extra.permissions import IsAuthenticated
 | 
				
			||||||
 | 
					from ninja_extra.schemas import PaginatedResponseSchema
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from com.calendar import IcsCalendar
 | 
					from com.calendar import IcsCalendar
 | 
				
			||||||
 | 
					from com.models import News, NewsDate
 | 
				
			||||||
 | 
					from com.schemas import NewsDateFilterSchema, NewsDateSchema
 | 
				
			||||||
 | 
					from core.auth.api_permissions import HasPerm
 | 
				
			||||||
from core.views.files import send_raw_file
 | 
					from core.views.files import send_raw_file
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -17,7 +25,7 @@ class CalendarController(ControllerBase):
 | 
				
			|||||||
        """Return the ICS file of the AE Google Calendar
 | 
					        """Return the ICS file of the AE Google Calendar
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Because of Google's cors rules, we can't just do a request to google ics
 | 
					        Because of Google's cors rules, we can't just do a request to google ics
 | 
				
			||||||
        from the frontend. Google is blocking CORS request in it's responses headers.
 | 
					        from the frontend. Google is blocking CORS request in its responses headers.
 | 
				
			||||||
        The only way to do it from the frontend is to use Google Calendar API with an API key
 | 
					        The only way to do it from the frontend is to use Google Calendar API with an API key
 | 
				
			||||||
        This is not especially desirable as your API key is going to be provided to the frontend.
 | 
					        This is not especially desirable as your API key is going to be provided to the frontend.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -30,3 +38,67 @@ class CalendarController(ControllerBase):
 | 
				
			|||||||
    @route.get("/internal.ics", url_name="calendar_internal")
 | 
					    @route.get("/internal.ics", url_name="calendar_internal")
 | 
				
			||||||
    def calendar_internal(self):
 | 
					    def calendar_internal(self):
 | 
				
			||||||
        return send_raw_file(IcsCalendar.get_internal())
 | 
					        return send_raw_file(IcsCalendar.get_internal())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @route.get(
 | 
				
			||||||
 | 
					        "/unpublished.ics",
 | 
				
			||||||
 | 
					        permissions=[IsAuthenticated],
 | 
				
			||||||
 | 
					        url_name="calendar_unpublished",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def calendar_unpublished(self):
 | 
				
			||||||
 | 
					        return HttpResponse(
 | 
				
			||||||
 | 
					            IcsCalendar.get_unpublished(self.context.request.user),
 | 
				
			||||||
 | 
					            content_type="text/calendar",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@api_controller("/news")
 | 
				
			||||||
 | 
					class NewsController(ControllerBase):
 | 
				
			||||||
 | 
					    @route.patch(
 | 
				
			||||||
 | 
					        "/{int:news_id}/publish",
 | 
				
			||||||
 | 
					        permissions=[HasPerm("com.moderate_news")],
 | 
				
			||||||
 | 
					        url_name="moderate_news",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def publish_news(self, news_id: int):
 | 
				
			||||||
 | 
					        news = self.get_object_or_exception(News, id=news_id)
 | 
				
			||||||
 | 
					        if not news.is_published:
 | 
				
			||||||
 | 
					            news.is_published = True
 | 
				
			||||||
 | 
					            news.moderator = self.context.request.user
 | 
				
			||||||
 | 
					            news.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @route.patch(
 | 
				
			||||||
 | 
					        "/{int:news_id}/unpublish",
 | 
				
			||||||
 | 
					        permissions=[HasPerm("com.moderate_news")],
 | 
				
			||||||
 | 
					        url_name="unpublish_news",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def unpublish_news(self, news_id: int):
 | 
				
			||||||
 | 
					        news = self.get_object_or_exception(News, id=news_id)
 | 
				
			||||||
 | 
					        if news.is_published:
 | 
				
			||||||
 | 
					            news.is_published = False
 | 
				
			||||||
 | 
					            news.moderator = self.context.request.user
 | 
				
			||||||
 | 
					            news.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @route.delete(
 | 
				
			||||||
 | 
					        "/{int:news_id}",
 | 
				
			||||||
 | 
					        permissions=[HasPerm("com.delete_news")],
 | 
				
			||||||
 | 
					        url_name="delete_news",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def delete_news(self, news_id: int):
 | 
				
			||||||
 | 
					        news = self.get_object_or_exception(News, id=news_id)
 | 
				
			||||||
 | 
					        news.delete()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @route.get(
 | 
				
			||||||
 | 
					        "/date",
 | 
				
			||||||
 | 
					        url_name="fetch_news_dates",
 | 
				
			||||||
 | 
					        response=PaginatedResponseSchema[NewsDateSchema],
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @paginate(PageNumberPaginationExtra, page_size=50)
 | 
				
			||||||
 | 
					    def fetch_news_dates(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        filters: Query[NewsDateFilterSchema],
 | 
				
			||||||
 | 
					        text_format: Literal["md", "html"] = "md",
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        return filters.filter(
 | 
				
			||||||
 | 
					            NewsDate.objects.viewable_by(self.context.request.user)
 | 
				
			||||||
 | 
					            .order_by("start_date")
 | 
				
			||||||
 | 
					            .select_related("news", "news__club")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,9 +2,10 @@ from datetime import datetime, timedelta
 | 
				
			|||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from typing import final
 | 
					from typing import final
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import urllib3
 | 
					import requests
 | 
				
			||||||
from dateutil.relativedelta import relativedelta
 | 
					from dateutil.relativedelta import relativedelta
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db.models import F, QuerySet
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
from ical.calendar import Calendar
 | 
					from ical.calendar import Calendar
 | 
				
			||||||
@@ -12,6 +13,7 @@ from ical.calendar_stream import IcsCalendarStream
 | 
				
			|||||||
from ical.event import Event
 | 
					from ical.event import Event
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from com.models import NewsDate
 | 
					from com.models import NewsDate
 | 
				
			||||||
 | 
					from core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@final
 | 
					@final
 | 
				
			||||||
@@ -35,16 +37,15 @@ class IcsCalendar:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def make_external(cls) -> Path | None:
 | 
					    def make_external(cls) -> Path | None:
 | 
				
			||||||
        calendar = urllib3.request(
 | 
					        calendar = requests.get(
 | 
				
			||||||
            "GET",
 | 
					            "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics"
 | 
				
			||||||
            "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics",
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        if calendar.status != 200:
 | 
					        if not calendar.ok:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
 | 
					        cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
        with open(cls._EXTERNAL_CALENDAR, "wb") as f:
 | 
					        with open(cls._EXTERNAL_CALENDAR, "wb") as f:
 | 
				
			||||||
            _ = f.write(calendar.data)
 | 
					            _ = f.write(calendar.content)
 | 
				
			||||||
        return cls._EXTERNAL_CALENDAR
 | 
					        return cls._EXTERNAL_CALENDAR
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
@@ -56,21 +57,38 @@ class IcsCalendar:
 | 
				
			|||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def make_internal(cls) -> Path:
 | 
					    def make_internal(cls) -> Path:
 | 
				
			||||||
        # Updated through a post_save signal on News in com.signals
 | 
					        # Updated through a post_save signal on News in com.signals
 | 
				
			||||||
 | 
					        # Create a file so we can offload the download to the reverse proxy if available
 | 
				
			||||||
 | 
					        cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
 | 
				
			||||||
 | 
					        with open(cls._INTERNAL_CALENDAR, "wb") as f:
 | 
				
			||||||
 | 
					            _ = f.write(
 | 
				
			||||||
 | 
					                cls.ics_from_queryset(
 | 
				
			||||||
 | 
					                    NewsDate.objects.filter(
 | 
				
			||||||
 | 
					                        news__is_published=True,
 | 
				
			||||||
 | 
					                        end_date__gte=timezone.now() - (relativedelta(months=6)),
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        return cls._INTERNAL_CALENDAR
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def get_unpublished(cls, user: User) -> bytes:
 | 
				
			||||||
 | 
					        return cls.ics_from_queryset(
 | 
				
			||||||
 | 
					            NewsDate.objects.viewable_by(user).filter(
 | 
				
			||||||
 | 
					                news__is_published=False,
 | 
				
			||||||
 | 
					                end_date__gte=timezone.now() - (relativedelta(months=6)),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
 | 
				
			||||||
        calendar = Calendar()
 | 
					        calendar = Calendar()
 | 
				
			||||||
        for news_date in NewsDate.objects.filter(
 | 
					        for news_date in queryset.annotate(news_title=F("news__title")):
 | 
				
			||||||
            news__is_moderated=True,
 | 
					 | 
				
			||||||
            end_date__gte=timezone.now() - (relativedelta(months=6)),
 | 
					 | 
				
			||||||
        ).prefetch_related("news"):
 | 
					 | 
				
			||||||
            event = Event(
 | 
					            event = Event(
 | 
				
			||||||
                summary=news_date.news.title,
 | 
					                summary=news_date.news_title,
 | 
				
			||||||
                start=news_date.start_date,
 | 
					                start=news_date.start_date,
 | 
				
			||||||
                end=news_date.end_date,
 | 
					                end=news_date.end_date,
 | 
				
			||||||
                url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
 | 
					                url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            calendar.events.append(event)
 | 
					            calendar.events.append(event)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Create a file so we can offload the download to the reverse proxy if available
 | 
					        return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")
 | 
				
			||||||
        cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
        with open(cls._INTERNAL_CALENDAR, "wb") as f:
 | 
					 | 
				
			||||||
            _ = f.write(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8"))
 | 
					 | 
				
			||||||
        return cls._INTERNAL_CALENDAR
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										193
									
								
								com/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								com/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,193 @@
 | 
				
			|||||||
 | 
					from datetime import date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from dateutil.relativedelta import relativedelta
 | 
				
			||||||
 | 
					from django import forms
 | 
				
			||||||
 | 
					from django.db.models import Exists, OuterRef
 | 
				
			||||||
 | 
					from django.forms import CheckboxInput
 | 
				
			||||||
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from club.models import Club
 | 
				
			||||||
 | 
					from club.widgets.select import AutoCompleteSelectClub
 | 
				
			||||||
 | 
					from com.models import News, NewsDate, Poster
 | 
				
			||||||
 | 
					from core.models import User
 | 
				
			||||||
 | 
					from core.utils import get_end_of_semester
 | 
				
			||||||
 | 
					from core.views.forms import SelectDateTime
 | 
				
			||||||
 | 
					from core.views.widgets.markdown import MarkdownInput
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PosterForm(forms.ModelForm):
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = Poster
 | 
				
			||||||
 | 
					        fields = [
 | 
				
			||||||
 | 
					            "name",
 | 
				
			||||||
 | 
					            "file",
 | 
				
			||||||
 | 
					            "club",
 | 
				
			||||||
 | 
					            "screens",
 | 
				
			||||||
 | 
					            "date_begin",
 | 
				
			||||||
 | 
					            "date_end",
 | 
				
			||||||
 | 
					            "display_time",
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        widgets = {"screens": forms.CheckboxSelectMultiple}
 | 
				
			||||||
 | 
					        help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    date_begin = forms.DateTimeField(
 | 
				
			||||||
 | 
					        label=_("Start date"),
 | 
				
			||||||
 | 
					        widget=SelectDateTime,
 | 
				
			||||||
 | 
					        required=True,
 | 
				
			||||||
 | 
					        initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    date_end = forms.DateTimeField(
 | 
				
			||||||
 | 
					        label=_("End date"), widget=SelectDateTime, required=False
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        self.user = kwargs.pop("user", None)
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        if self.user and not self.user.is_com_admin:
 | 
				
			||||||
 | 
					            self.fields["club"].queryset = Club.objects.filter(
 | 
				
			||||||
 | 
					                id__in=self.user.clubs_with_rights
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            self.fields.pop("display_time")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsDateForm(forms.ModelForm):
 | 
				
			||||||
 | 
					    """Form to select the dates of an event."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    required_css_class = "required"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = NewsDate
 | 
				
			||||||
 | 
					        fields = ["start_date", "end_date"]
 | 
				
			||||||
 | 
					        widgets = {"start_date": SelectDateTime, "end_date": SelectDateTime}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    is_weekly = forms.BooleanField(
 | 
				
			||||||
 | 
					        label=_("Weekly event"),
 | 
				
			||||||
 | 
					        help_text=_("Weekly events will occur each week for a specified timespan."),
 | 
				
			||||||
 | 
					        widget=CheckboxInput(attrs={"class": "switch"}),
 | 
				
			||||||
 | 
					        initial=False,
 | 
				
			||||||
 | 
					        required=False,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    occurrence_choices = [
 | 
				
			||||||
 | 
					        *[(str(i), _("%d times") % i) for i in range(2, 7)],
 | 
				
			||||||
 | 
					        ("SEMESTER_END", _("Until the end of the semester")),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    occurrences = forms.ChoiceField(
 | 
				
			||||||
 | 
					        label=_("Occurrences"),
 | 
				
			||||||
 | 
					        help_text=_("How much times should the event occur (including the first one)"),
 | 
				
			||||||
 | 
					        choices=occurrence_choices,
 | 
				
			||||||
 | 
					        initial="SEMESTER_END",
 | 
				
			||||||
 | 
					        required=False,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        self.label_suffix = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def get_occurrences(cls, number: int) -> tuple[str, str] | None:
 | 
				
			||||||
 | 
					        """Find the occurrence choice corresponding to numeric number of occurrences."""
 | 
				
			||||||
 | 
					        if number < 2:
 | 
				
			||||||
 | 
					            # If only 0 or 1 date, there cannot be weekly events
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        # occurrences have all a numeric value, except "SEMESTER_END"
 | 
				
			||||||
 | 
					        str_num = str(number)
 | 
				
			||||||
 | 
					        occurrences = next((c for c in cls.occurrence_choices if c[0] == str_num), None)
 | 
				
			||||||
 | 
					        if occurrences:
 | 
				
			||||||
 | 
					            return occurrences
 | 
				
			||||||
 | 
					        return next((c for c in cls.occurrence_choices if c[0] == "SEMESTER_END"), None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, commit: bool = True, *, news: News):  # noqa FBT001
 | 
				
			||||||
 | 
					        # the base save method contains some checks we want to run
 | 
				
			||||||
 | 
					        # before doing our own logic
 | 
				
			||||||
 | 
					        super().save(commit=False)
 | 
				
			||||||
 | 
					        # delete existing dates before creating new ones
 | 
				
			||||||
 | 
					        news.dates.all().delete()
 | 
				
			||||||
 | 
					        if not self.cleaned_data.get("is_weekly"):
 | 
				
			||||||
 | 
					            self.instance.news = news
 | 
				
			||||||
 | 
					            return super().save(commit=commit)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        dates: list[NewsDate] = [self.instance]
 | 
				
			||||||
 | 
					        occurrences = self.cleaned_data.get("occurrences")
 | 
				
			||||||
 | 
					        start = self.instance.start_date
 | 
				
			||||||
 | 
					        end = self.instance.end_date
 | 
				
			||||||
 | 
					        if occurrences[0].isdigit():
 | 
				
			||||||
 | 
					            nb_occurrences = int(occurrences[0])
 | 
				
			||||||
 | 
					        else:  # to the end of the semester
 | 
				
			||||||
 | 
					            start_date = date(start.year, start.month, start.day)
 | 
				
			||||||
 | 
					            nb_occurrences = (get_end_of_semester(start_date) - start_date).days // 7
 | 
				
			||||||
 | 
					        dates.extend(
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                NewsDate(
 | 
				
			||||||
 | 
					                    start_date=start + relativedelta(weeks=i),
 | 
				
			||||||
 | 
					                    end_date=end + relativedelta(weeks=i),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                for i in range(1, nb_occurrences)
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        for d in dates:
 | 
				
			||||||
 | 
					            d.news = news
 | 
				
			||||||
 | 
					        if not commit:
 | 
				
			||||||
 | 
					            return dates
 | 
				
			||||||
 | 
					        return NewsDate.objects.bulk_create(dates)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsForm(forms.ModelForm):
 | 
				
			||||||
 | 
					    """Form to create or edit news."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    error_css_class = "error"
 | 
				
			||||||
 | 
					    required_css_class = "required"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = News
 | 
				
			||||||
 | 
					        fields = ["title", "club", "summary", "content"]
 | 
				
			||||||
 | 
					        widgets = {
 | 
				
			||||||
 | 
					            "author": forms.HiddenInput,
 | 
				
			||||||
 | 
					            "summary": MarkdownInput,
 | 
				
			||||||
 | 
					            "content": MarkdownInput,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    auto_publish = forms.BooleanField(
 | 
				
			||||||
 | 
					        label=_("Auto publication"),
 | 
				
			||||||
 | 
					        widget=CheckboxInput(attrs={"class": "switch"}),
 | 
				
			||||||
 | 
					        required=False,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, author: User, date_form: NewsDateForm, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        self.author = author
 | 
				
			||||||
 | 
					        self.date_form = date_form
 | 
				
			||||||
 | 
					        self.label_suffix = ""
 | 
				
			||||||
 | 
					        # if the author is an admin, he/she can choose any club,
 | 
				
			||||||
 | 
					        # otherwise, only clubs for which he/she is a board member can be selected
 | 
				
			||||||
 | 
					        if author.is_root or author.is_com_admin:
 | 
				
			||||||
 | 
					            self.fields["club"] = forms.ModelChoiceField(
 | 
				
			||||||
 | 
					                queryset=Club.objects.all(), widget=AutoCompleteSelectClub
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            active_memberships = author.memberships.board().ongoing()
 | 
				
			||||||
 | 
					            self.fields["club"] = forms.ModelChoiceField(
 | 
				
			||||||
 | 
					                queryset=Club.objects.filter(
 | 
				
			||||||
 | 
					                    Exists(active_memberships.filter(club=OuterRef("pk")))
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_valid(self):
 | 
				
			||||||
 | 
					        return super().is_valid() and self.date_form.is_valid()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def full_clean(self):
 | 
				
			||||||
 | 
					        super().full_clean()
 | 
				
			||||||
 | 
					        self.date_form.full_clean()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, commit: bool = True):  # noqa FBT001
 | 
				
			||||||
 | 
					        self.instance.author = self.author
 | 
				
			||||||
 | 
					        if (self.author.is_com_admin or self.author.is_root) and (
 | 
				
			||||||
 | 
					            self.cleaned_data.get("auto_publish") is True
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            self.instance.is_published = True
 | 
				
			||||||
 | 
					            self.instance.moderator = self.author
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.instance.is_published = False
 | 
				
			||||||
 | 
					        created_news = super().save(commit=commit)
 | 
				
			||||||
 | 
					        self.date_form.save(commit=commit, news=created_news)
 | 
				
			||||||
 | 
					        return created_news
 | 
				
			||||||
@@ -0,0 +1,61 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.2.17 on 2025-01-06 21:52
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
				
			||||||
 | 
					        ("com", "0007_alter_news_club_alter_news_content_and_more"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterModelOptions(
 | 
				
			||||||
 | 
					            name="news",
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                "verbose_name": "news",
 | 
				
			||||||
 | 
					                "permissions": [
 | 
				
			||||||
 | 
					                    ("moderate_news", "Can moderate news"),
 | 
				
			||||||
 | 
					                    ("view_unmoderated_news", "Can view non-moderated news"),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterModelOptions(
 | 
				
			||||||
 | 
					            name="newsdate",
 | 
				
			||||||
 | 
					            options={"verbose_name": "news date", "verbose_name_plural": "news dates"},
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterModelOptions(
 | 
				
			||||||
 | 
					            name="poster",
 | 
				
			||||||
 | 
					            options={"permissions": [("moderate_poster", "Can moderate poster")]},
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RemoveField(model_name="news", name="type"),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="news",
 | 
				
			||||||
 | 
					            name="author",
 | 
				
			||||||
 | 
					            field=models.ForeignKey(
 | 
				
			||||||
 | 
					                on_delete=django.db.models.deletion.PROTECT,
 | 
				
			||||||
 | 
					                related_name="owned_news",
 | 
				
			||||||
 | 
					                to=settings.AUTH_USER_MODEL,
 | 
				
			||||||
 | 
					                verbose_name="author",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="newsdate",
 | 
				
			||||||
 | 
					            name="end_date",
 | 
				
			||||||
 | 
					            field=models.DateTimeField(verbose_name="end_date"),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="newsdate",
 | 
				
			||||||
 | 
					            name="start_date",
 | 
				
			||||||
 | 
					            field=models.DateTimeField(verbose_name="start_date"),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddConstraint(
 | 
				
			||||||
 | 
					            model_name="newsdate",
 | 
				
			||||||
 | 
					            constraint=models.CheckConstraint(
 | 
				
			||||||
 | 
					                check=models.Q(("end_date__gte", models.F("start_date"))),
 | 
				
			||||||
 | 
					                name="news_date_end_date_after_start_date",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					    dependencies = [("com", "0008_alter_news_options_alter_newsdate_options_and_more")]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.RenameField(
 | 
				
			||||||
 | 
					            model_name="news", old_name="is_moderated", new_name="is_published"
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="news",
 | 
				
			||||||
 | 
					            name="is_published",
 | 
				
			||||||
 | 
					            field=models.BooleanField(default=False, verbose_name="is published"),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										124
									
								
								com/models.py
									
									
									
									
									
								
							
							
						
						
									
										124
									
								
								com/models.py
									
									
									
									
									
								
							@@ -21,13 +21,13 @@
 | 
				
			|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
					# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					from typing import Self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.exceptions import ValidationError
 | 
					from django.core.exceptions import ValidationError
 | 
				
			||||||
from django.core.mail import EmailMultiAlternatives
 | 
					from django.core.mail import EmailMultiAlternatives
 | 
				
			||||||
from django.db import models, transaction
 | 
					from django.db import models, transaction
 | 
				
			||||||
from django.db.models import Q
 | 
					from django.db.models import F, Q
 | 
				
			||||||
from django.shortcuts import render
 | 
					from django.shortcuts import render
 | 
				
			||||||
from django.templatetags.static import static
 | 
					from django.templatetags.static import static
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
@@ -54,12 +54,24 @@ class Sith(models.Model):
 | 
				
			|||||||
        return user.is_com_admin
 | 
					        return user.is_com_admin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
NEWS_TYPES = [
 | 
					class NewsQuerySet(models.QuerySet):
 | 
				
			||||||
    ("NOTICE", _("Notice")),
 | 
					    def moderated(self) -> Self:
 | 
				
			||||||
    ("EVENT", _("Event")),
 | 
					        return self.filter(is_published=True)
 | 
				
			||||||
    ("WEEKLY", _("Weekly")),
 | 
					
 | 
				
			||||||
    ("CALL", _("Call")),
 | 
					    def viewable_by(self, user: User) -> Self:
 | 
				
			||||||
]
 | 
					        """Filter news that the given user can view.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        If the user has the `com.view_unmoderated_news` permission,
 | 
				
			||||||
 | 
					        all news are viewable.
 | 
				
			||||||
 | 
					        Else the viewable news are those that are either moderated
 | 
				
			||||||
 | 
					        or authored by the user.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if user.has_perm("com.view_unmoderated_news"):
 | 
				
			||||||
 | 
					            return self
 | 
				
			||||||
 | 
					        q_filter = Q(is_published=True)
 | 
				
			||||||
 | 
					        if user.is_authenticated:
 | 
				
			||||||
 | 
					            q_filter |= Q(author_id=user.id)
 | 
				
			||||||
 | 
					        return self.filter(q_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class News(models.Model):
 | 
					class News(models.Model):
 | 
				
			||||||
@@ -79,9 +91,6 @@ class News(models.Model):
 | 
				
			|||||||
        default="",
 | 
					        default="",
 | 
				
			||||||
        help_text=_("A more detailed and exhaustive description of the event."),
 | 
					        help_text=_("A more detailed and exhaustive description of the event."),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    type = models.CharField(
 | 
					 | 
				
			||||||
        _("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    club = models.ForeignKey(
 | 
					    club = models.ForeignKey(
 | 
				
			||||||
        Club,
 | 
					        Club,
 | 
				
			||||||
        related_name="news",
 | 
					        related_name="news",
 | 
				
			||||||
@@ -93,9 +102,9 @@ class News(models.Model):
 | 
				
			|||||||
        User,
 | 
					        User,
 | 
				
			||||||
        related_name="owned_news",
 | 
					        related_name="owned_news",
 | 
				
			||||||
        verbose_name=_("author"),
 | 
					        verbose_name=_("author"),
 | 
				
			||||||
        on_delete=models.CASCADE,
 | 
					        on_delete=models.PROTECT,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    is_moderated = models.BooleanField(_("is moderated"), default=False)
 | 
					    is_published = models.BooleanField(_("is published"), default=False)
 | 
				
			||||||
    moderator = models.ForeignKey(
 | 
					    moderator = models.ForeignKey(
 | 
				
			||||||
        User,
 | 
					        User,
 | 
				
			||||||
        related_name="moderated_news",
 | 
					        related_name="moderated_news",
 | 
				
			||||||
@@ -104,19 +113,27 @@ class News(models.Model):
 | 
				
			|||||||
        on_delete=models.SET_NULL,
 | 
					        on_delete=models.SET_NULL,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = NewsQuerySet.as_manager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = _("news")
 | 
				
			||||||
 | 
					        permissions = [
 | 
				
			||||||
 | 
					            ("moderate_news", "Can moderate news"),
 | 
				
			||||||
 | 
					            ("view_unmoderated_news", "Can view non-moderated news"),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return "%s: %s" % (self.type, self.title)
 | 
					        return self.title
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
        super().save(*args, **kwargs)
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
 | 
					        if self.is_published:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
        for user in User.objects.filter(
 | 
					        for user in User.objects.filter(
 | 
				
			||||||
            groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
 | 
					            groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
 | 
				
			||||||
        ):
 | 
					        ):
 | 
				
			||||||
            Notification.objects.create(
 | 
					            Notification.objects.create(
 | 
				
			||||||
                user=user,
 | 
					                user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
 | 
				
			||||||
                url=reverse("com:news_admin_list"),
 | 
					 | 
				
			||||||
                type="NEWS_MODERATION",
 | 
					 | 
				
			||||||
                param="1",
 | 
					 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_absolute_url(self):
 | 
					    def get_absolute_url(self):
 | 
				
			||||||
@@ -130,35 +147,51 @@ class News(models.Model):
 | 
				
			|||||||
            return False
 | 
					            return False
 | 
				
			||||||
        return user.is_com_admin or user == self.author
 | 
					        return user.is_com_admin or user == self.author
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def can_be_edited_by(self, user):
 | 
					    def can_be_edited_by(self, user: User):
 | 
				
			||||||
        return user.is_com_admin
 | 
					        return user.is_authenticated and (
 | 
				
			||||||
 | 
					            self.author_id == user.id or user.has_perm("com.change_news")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def can_be_viewed_by(self, user):
 | 
					    def can_be_viewed_by(self, user: User):
 | 
				
			||||||
        return self.is_moderated or user.is_com_admin
 | 
					        return (
 | 
				
			||||||
 | 
					            self.is_published
 | 
				
			||||||
 | 
					            or user.has_perm("com.view_unmoderated_news")
 | 
				
			||||||
 | 
					            or (user.is_authenticated and self.author_id == user.id)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def news_notification_callback(notif):
 | 
					def news_notification_callback(notif):
 | 
				
			||||||
    count = (
 | 
					    count = News.objects.filter(
 | 
				
			||||||
        News.objects.filter(
 | 
					        dates__start_date__gt=timezone.now(), is_published=False
 | 
				
			||||||
            Q(dates__start_date__gt=timezone.now(), is_moderated=False)
 | 
					    ).count()
 | 
				
			||||||
            | Q(type="NOTICE", is_moderated=False)
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .distinct()
 | 
					 | 
				
			||||||
        .count()
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    if count:
 | 
					    if count:
 | 
				
			||||||
        notif.viewed = False
 | 
					        notif.viewed = False
 | 
				
			||||||
        notif.param = "%s" % count
 | 
					        notif.param = str(count)
 | 
				
			||||||
        notif.date = timezone.now()
 | 
					        notif.date = timezone.now()
 | 
				
			||||||
    else:
 | 
					    else:
 | 
				
			||||||
        notif.viewed = True
 | 
					        notif.viewed = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NewsDate(models.Model):
 | 
					class NewsDateQuerySet(models.QuerySet):
 | 
				
			||||||
    """A date class, useful for weekly events, or for events that just have no date.
 | 
					    def viewable_by(self, user: User) -> Self:
 | 
				
			||||||
 | 
					        """Filter the event dates that the given user can view.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    This class allows more flexibilty managing the dates related to a news, particularly when this news is weekly, since
 | 
					        - If the can view non moderated news, he can view all news dates
 | 
				
			||||||
    we don't have to make copies
 | 
					        - else, he can view the dates of news that are either
 | 
				
			||||||
 | 
					          authored by him or moderated.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if user.has_perm("com.view_unmoderated_news"):
 | 
				
			||||||
 | 
					            return self
 | 
				
			||||||
 | 
					        q_filter = Q(news__is_published=True)
 | 
				
			||||||
 | 
					        if user.is_authenticated:
 | 
				
			||||||
 | 
					            q_filter |= Q(news__author_id=user.id)
 | 
				
			||||||
 | 
					        return self.filter(q_filter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsDate(models.Model):
 | 
				
			||||||
 | 
					    """A date associated with news.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    A [News][] can have multiple dates, for example if it is a recurring event.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    news = models.ForeignKey(
 | 
					    news = models.ForeignKey(
 | 
				
			||||||
@@ -167,11 +200,23 @@ class NewsDate(models.Model):
 | 
				
			|||||||
        verbose_name=_("news_date"),
 | 
					        verbose_name=_("news_date"),
 | 
				
			||||||
        on_delete=models.CASCADE,
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    start_date = models.DateTimeField(_("start_date"), null=True, blank=True)
 | 
					    start_date = models.DateTimeField(_("start_date"))
 | 
				
			||||||
    end_date = models.DateTimeField(_("end_date"), null=True, blank=True)
 | 
					    end_date = models.DateTimeField(_("end_date"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = NewsDateQuerySet.as_manager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = _("news date")
 | 
				
			||||||
 | 
					        verbose_name_plural = _("news dates")
 | 
				
			||||||
 | 
					        constraints = [
 | 
				
			||||||
 | 
					            models.CheckConstraint(
 | 
				
			||||||
 | 
					                check=Q(end_date__gte=F("start_date")),
 | 
				
			||||||
 | 
					                name="news_date_end_date_after_start_date",
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return "%s: %s - %s" % (self.news.title, self.start_date, self.end_date)
 | 
					        return f"{self.news.title}: {self.start_date} - {self.end_date}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Weekmail(models.Model):
 | 
					class Weekmail(models.Model):
 | 
				
			||||||
@@ -330,6 +375,9 @@ class Poster(models.Model):
 | 
				
			|||||||
        on_delete=models.CASCADE,
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        permissions = [("moderate_poster", "Can moderate poster")]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return self.name
 | 
					        return self.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										58
									
								
								com/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								com/schemas.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,58 @@
 | 
				
			|||||||
 | 
					from datetime import datetime
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from ninja import FilterSchema, ModelSchema
 | 
				
			||||||
 | 
					from ninja_extra import service_resolver
 | 
				
			||||||
 | 
					from ninja_extra.controllers import RouteContext
 | 
				
			||||||
 | 
					from pydantic import Field
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from club.schemas import ClubProfileSchema
 | 
				
			||||||
 | 
					from com.models import News, NewsDate
 | 
				
			||||||
 | 
					from core.markdown import markdown
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsDateFilterSchema(FilterSchema):
 | 
				
			||||||
 | 
					    before: datetime | None = Field(None, q="end_date__lt")
 | 
				
			||||||
 | 
					    after: datetime | None = Field(None, q="start_date__gt")
 | 
				
			||||||
 | 
					    club_id: int | None = Field(None, q="news__club_id")
 | 
				
			||||||
 | 
					    news_id: int | None = None
 | 
				
			||||||
 | 
					    is_published: bool | None = Field(None, q="news__is_published")
 | 
				
			||||||
 | 
					    title: str | None = Field(None, q="news__title__icontains")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsSchema(ModelSchema):
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = News
 | 
				
			||||||
 | 
					        fields = ["id", "title", "summary", "is_published"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    club: ClubProfileSchema
 | 
				
			||||||
 | 
					    url: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def resolve_summary(obj: News) -> str:
 | 
				
			||||||
 | 
					        # if this is returned from a route that allows the
 | 
				
			||||||
 | 
					        # user to choose the text format (md or html)
 | 
				
			||||||
 | 
					        # and the user chose "html", convert the markdown to html
 | 
				
			||||||
 | 
					        context: RouteContext = service_resolver(RouteContext)
 | 
				
			||||||
 | 
					        if context.kwargs.get("text_format", "") == "html":
 | 
				
			||||||
 | 
					            return markdown(obj.summary)
 | 
				
			||||||
 | 
					        return obj.summary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def resolve_url(obj: News) -> str:
 | 
				
			||||||
 | 
					        return obj.get_absolute_url()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsDateSchema(ModelSchema):
 | 
				
			||||||
 | 
					    """Basic infos about an event occurrence.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Warning:
 | 
				
			||||||
 | 
					        This uses [NewsSchema][], which itself
 | 
				
			||||||
 | 
					        uses [ClubProfileSchema][club.schemas.ClubProfileSchema].
 | 
				
			||||||
 | 
					        Don't forget the appropriated `select_related`.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = NewsDate
 | 
				
			||||||
 | 
					        fields = ["id", "start_date", "end_date"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    news: NewsSchema
 | 
				
			||||||
@@ -1,10 +1,10 @@
 | 
				
			|||||||
from django.db.models.base import post_save
 | 
					from django.db.models.signals import post_delete, post_save
 | 
				
			||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from com.calendar import IcsCalendar
 | 
					from com.calendar import IcsCalendar
 | 
				
			||||||
from com.models import News
 | 
					from com.models import News
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(post_save, sender=News, dispatch_uid="update_internal_ics")
 | 
					@receiver([post_save, post_delete], sender=News, dispatch_uid="update_internal_ics")
 | 
				
			||||||
def update_internal_ics(*args, **kwargs):
 | 
					def update_internal_ics(*args, **kwargs):
 | 
				
			||||||
    _ = IcsCalendar.make_internal()
 | 
					    _ = IcsCalendar.make_internal()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,20 +7,33 @@ import frLocale from "@fullcalendar/core/locales/fr";
 | 
				
			|||||||
import dayGridPlugin from "@fullcalendar/daygrid";
 | 
					import dayGridPlugin from "@fullcalendar/daygrid";
 | 
				
			||||||
import iCalendarPlugin from "@fullcalendar/icalendar";
 | 
					import iCalendarPlugin from "@fullcalendar/icalendar";
 | 
				
			||||||
import listPlugin from "@fullcalendar/list";
 | 
					import listPlugin from "@fullcalendar/list";
 | 
				
			||||||
import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi";
 | 
					import {
 | 
				
			||||||
 | 
					  calendarCalendarExternal,
 | 
				
			||||||
 | 
					  calendarCalendarInternal,
 | 
				
			||||||
 | 
					  calendarCalendarUnpublished,
 | 
				
			||||||
 | 
					  newsDeleteNews,
 | 
				
			||||||
 | 
					  newsPublishNews,
 | 
				
			||||||
 | 
					  newsUnpublishNews,
 | 
				
			||||||
 | 
					} from "#openapi";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@registerComponent("ics-calendar")
 | 
					@registerComponent("ics-calendar")
 | 
				
			||||||
export class IcsCalendar extends inheritHtmlElement("div") {
 | 
					export class IcsCalendar extends inheritHtmlElement("div") {
 | 
				
			||||||
  static observedAttributes = ["locale"];
 | 
					  static observedAttributes = ["locale", "can_moderate", "can_delete"];
 | 
				
			||||||
  private calendar: Calendar;
 | 
					  private calendar: Calendar;
 | 
				
			||||||
  private locale = "en";
 | 
					  private locale = "en";
 | 
				
			||||||
 | 
					  private canModerate = false;
 | 
				
			||||||
 | 
					  private canDelete = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
 | 
					  attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
 | 
				
			||||||
    if (name !== "locale") {
 | 
					    if (name === "locale") {
 | 
				
			||||||
      return;
 | 
					      this.locale = newValue;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (name === "can_moderate") {
 | 
				
			||||||
 | 
					      this.canModerate = newValue.toLowerCase() === "true";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (name === "can_delete") {
 | 
				
			||||||
 | 
					      this.canDelete = newValue.toLowerCase() === "true";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    this.locale = newValue;
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  isMobile() {
 | 
					  isMobile() {
 | 
				
			||||||
@@ -54,6 +67,104 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
				
			|||||||
    }).format(date);
 | 
					    }).format(date);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getNewsId(event: EventImpl) {
 | 
				
			||||||
 | 
					    return Number.parseInt(
 | 
				
			||||||
 | 
					      event.url
 | 
				
			||||||
 | 
					        .toString()
 | 
				
			||||||
 | 
					        .split("/")
 | 
				
			||||||
 | 
					        .filter((s) => s) // Remove blank characters
 | 
				
			||||||
 | 
					        .pop(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async refreshEvents() {
 | 
				
			||||||
 | 
					    this.click(); // Remove focus from popup
 | 
				
			||||||
 | 
					    // We can't just refresh events because some ics files are in
 | 
				
			||||||
 | 
					    // local browser cache (especially internal.ics)
 | 
				
			||||||
 | 
					    // To invalidate the cache, we need to remove the source and add it again
 | 
				
			||||||
 | 
					    this.calendar.removeAllEventSources();
 | 
				
			||||||
 | 
					    for (const source of await this.getEventSources()) {
 | 
				
			||||||
 | 
					      this.calendar.addEventSource(source);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    this.calendar.refetchEvents();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async publishNews(id: number) {
 | 
				
			||||||
 | 
					    await newsPublishNews({
 | 
				
			||||||
 | 
					      path: {
 | 
				
			||||||
 | 
					        // biome-ignore lint/style/useNamingConvention: python API
 | 
				
			||||||
 | 
					        news_id: id,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    this.dispatchEvent(
 | 
				
			||||||
 | 
					      new CustomEvent("calendar-publish", {
 | 
				
			||||||
 | 
					        bubbles: true,
 | 
				
			||||||
 | 
					        detail: {
 | 
				
			||||||
 | 
					          id: id,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    await this.refreshEvents();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async unpublishNews(id: number) {
 | 
				
			||||||
 | 
					    await newsUnpublishNews({
 | 
				
			||||||
 | 
					      path: {
 | 
				
			||||||
 | 
					        // biome-ignore lint/style/useNamingConvention: python API
 | 
				
			||||||
 | 
					        news_id: id,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    this.dispatchEvent(
 | 
				
			||||||
 | 
					      new CustomEvent("calendar-unpublish", {
 | 
				
			||||||
 | 
					        bubbles: true,
 | 
				
			||||||
 | 
					        detail: {
 | 
				
			||||||
 | 
					          id: id,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    await this.refreshEvents();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async deleteNews(id: number) {
 | 
				
			||||||
 | 
					    await newsDeleteNews({
 | 
				
			||||||
 | 
					      path: {
 | 
				
			||||||
 | 
					        // biome-ignore lint/style/useNamingConvention: python API
 | 
				
			||||||
 | 
					        news_id: id,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    this.dispatchEvent(
 | 
				
			||||||
 | 
					      new CustomEvent("calendar-delete", {
 | 
				
			||||||
 | 
					        bubbles: true,
 | 
				
			||||||
 | 
					        detail: {
 | 
				
			||||||
 | 
					          id: id,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    await this.refreshEvents();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getEventSources() {
 | 
				
			||||||
 | 
					    const cacheInvalidate = `?invalidate=${Date.now()}`;
 | 
				
			||||||
 | 
					    return [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
 | 
				
			||||||
 | 
					        format: "ics",
 | 
				
			||||||
 | 
					        className: "internal",
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
 | 
				
			||||||
 | 
					        format: "ics",
 | 
				
			||||||
 | 
					        className: "external",
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
 | 
				
			||||||
 | 
					        format: "ics",
 | 
				
			||||||
 | 
					        color: "red",
 | 
				
			||||||
 | 
					        className: "unpublished",
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  createEventDetailPopup(event: EventClickArg) {
 | 
					  createEventDetailPopup(event: EventClickArg) {
 | 
				
			||||||
    // Delete previous popup
 | 
					    // Delete previous popup
 | 
				
			||||||
    const oldPopup = document.getElementById("event-details");
 | 
					    const oldPopup = document.getElementById("event-details");
 | 
				
			||||||
@@ -112,6 +223,47 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
				
			|||||||
      return makePopupInfo(url, "fa-solid fa-link");
 | 
					      return makePopupInfo(url, "fa-solid fa-link");
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const makePopupTools = (event: EventImpl) => {
 | 
				
			||||||
 | 
					      if (event.source.internalEventSource.ui.classNames.includes("external")) {
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (!(this.canDelete || this.canModerate)) {
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const newsId = this.getNewsId(event);
 | 
				
			||||||
 | 
					      const div = document.createElement("div");
 | 
				
			||||||
 | 
					      if (this.canModerate) {
 | 
				
			||||||
 | 
					        if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
 | 
				
			||||||
 | 
					          const button = document.createElement("button");
 | 
				
			||||||
 | 
					          button.innerHTML = `<i class="fa fa-check"></i>${gettext("Publish")}`;
 | 
				
			||||||
 | 
					          button.setAttribute("class", "btn btn-green");
 | 
				
			||||||
 | 
					          button.onclick = () => {
 | 
				
			||||||
 | 
					            this.publishNews(newsId);
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          div.appendChild(button);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          const button = document.createElement("button");
 | 
				
			||||||
 | 
					          button.innerHTML = `<i class="fa fa-times"></i>${gettext("Unpublish")}`;
 | 
				
			||||||
 | 
					          button.setAttribute("class", "btn btn-orange");
 | 
				
			||||||
 | 
					          button.onclick = () => {
 | 
				
			||||||
 | 
					            this.unpublishNews(newsId);
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          div.appendChild(button);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (this.canDelete) {
 | 
				
			||||||
 | 
					        const button = document.createElement("button");
 | 
				
			||||||
 | 
					        button.innerHTML = `<i class="fa fa-trash-can"></i>${gettext("Delete")}`;
 | 
				
			||||||
 | 
					        button.setAttribute("class", "btn btn-red");
 | 
				
			||||||
 | 
					        button.onclick = () => {
 | 
				
			||||||
 | 
					          this.deleteNews(newsId);
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        div.appendChild(button);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return makePopupInfo(div, "fa-solid fa-toolbox");
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Create new popup
 | 
					    // Create new popup
 | 
				
			||||||
    const popup = document.createElement("div");
 | 
					    const popup = document.createElement("div");
 | 
				
			||||||
    const popupContainer = document.createElement("div");
 | 
					    const popupContainer = document.createElement("div");
 | 
				
			||||||
@@ -131,6 +283,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
				
			|||||||
      popupContainer.appendChild(url);
 | 
					      popupContainer.appendChild(url);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const tools = makePopupTools(event.event);
 | 
				
			||||||
 | 
					    if (tools !== null) {
 | 
				
			||||||
 | 
					      popupContainer.appendChild(tools);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    popup.appendChild(popupContainer);
 | 
					    popup.appendChild(popupContainer);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // We can't just add the element relative to the one we want to appear under
 | 
					    // We can't just add the element relative to the one we want to appear under
 | 
				
			||||||
@@ -159,16 +316,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
				
			|||||||
      locale: this.locale,
 | 
					      locale: this.locale,
 | 
				
			||||||
      initialView: this.currentView(),
 | 
					      initialView: this.currentView(),
 | 
				
			||||||
      headerToolbar: this.currentToolbar(),
 | 
					      headerToolbar: this.currentToolbar(),
 | 
				
			||||||
      eventSources: [
 | 
					      eventSources: await this.getEventSources(),
 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          url: await makeUrl(calendarCalendarInternal),
 | 
					 | 
				
			||||||
          format: "ics",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          url: await makeUrl(calendarCalendarExternal),
 | 
					 | 
				
			||||||
          format: "ics",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      ],
 | 
					 | 
				
			||||||
      windowResize: () => {
 | 
					      windowResize: () => {
 | 
				
			||||||
        this.calendar.changeView(this.currentView());
 | 
					        this.calendar.changeView(this.currentView());
 | 
				
			||||||
        this.calendar.setOption("headerToolbar", this.currentToolbar());
 | 
					        this.calendar.setOption("headerToolbar", this.currentToolbar());
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										81
									
								
								com/static/bundled/com/components/moderation-alert-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								com/static/bundled/com/components/moderation-alert-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
				
			|||||||
 | 
					import { exportToHtml } from "#core:utils/globals";
 | 
				
			||||||
 | 
					import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// This will be used in jinja templates,
 | 
				
			||||||
 | 
					// so we cannot use real enums as those are purely an abstraction of Typescript
 | 
				
			||||||
 | 
					const AlertState = {
 | 
				
			||||||
 | 
					  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
				
			||||||
 | 
					  PENDING: 1,
 | 
				
			||||||
 | 
					  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
				
			||||||
 | 
					  PUBLISHED: 2,
 | 
				
			||||||
 | 
					  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
				
			||||||
 | 
					  DELETED: 3,
 | 
				
			||||||
 | 
					  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
				
			||||||
 | 
					  DISPLAYED: 4, // When published at page generation
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					exportToHtml("AlertState", AlertState);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					document.addEventListener("alpine:init", () => {
 | 
				
			||||||
 | 
					  Alpine.data("moderationAlert", (newsId: number) => ({
 | 
				
			||||||
 | 
					    state: AlertState.PENDING,
 | 
				
			||||||
 | 
					    newsId: newsId as number,
 | 
				
			||||||
 | 
					    loading: false,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async publishNews() {
 | 
				
			||||||
 | 
					      this.loading = true;
 | 
				
			||||||
 | 
					      // biome-ignore lint/style/useNamingConvention: api is snake case
 | 
				
			||||||
 | 
					      await newsPublishNews({ path: { news_id: this.newsId } });
 | 
				
			||||||
 | 
					      this.state = AlertState.PUBLISHED;
 | 
				
			||||||
 | 
					      this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
 | 
				
			||||||
 | 
					      this.loading = false;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async deleteNews() {
 | 
				
			||||||
 | 
					      this.loading = true;
 | 
				
			||||||
 | 
					      // biome-ignore lint/style/useNamingConvention: api is snake case
 | 
				
			||||||
 | 
					      await newsDeleteNews({ path: { news_id: this.newsId } });
 | 
				
			||||||
 | 
					      this.state = AlertState.DELETED;
 | 
				
			||||||
 | 
					      this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
 | 
				
			||||||
 | 
					      this.loading = false;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Event receiver for when news dates are moderated.
 | 
				
			||||||
 | 
					     *
 | 
				
			||||||
 | 
					     * If the moderated date is linked to the same news
 | 
				
			||||||
 | 
					     * as the one this moderation alert is attached to,
 | 
				
			||||||
 | 
					     * then set the alert state to the same as the moderated one.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    dispatchModeration(event: CustomEvent) {
 | 
				
			||||||
 | 
					      if (event.detail.newsId === this.newsId) {
 | 
				
			||||||
 | 
					        this.state = event.detail.state;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Query the server to know the number of news dates that would be moderated
 | 
				
			||||||
 | 
					     * if this one is moderated.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    async nbToPublish(): Promise<number> {
 | 
				
			||||||
 | 
					      // What we want here is the count attribute of the response.
 | 
				
			||||||
 | 
					      // We don't care about the actual results,
 | 
				
			||||||
 | 
					      // so we ask for the minimum page size possible.
 | 
				
			||||||
 | 
					      const response = await newsFetchNewsDates({
 | 
				
			||||||
 | 
					        // biome-ignore lint/style/useNamingConvention: api is snake-case
 | 
				
			||||||
 | 
					        query: { news_id: this.newsId, page: 1, page_size: 1 },
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      return response.data.count;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    weeklyEventWarningMessage(nbEvents: number): string {
 | 
				
			||||||
 | 
					      return interpolate(
 | 
				
			||||||
 | 
					        gettext(
 | 
				
			||||||
 | 
					          "This event will take place every week for %s weeks. " +
 | 
				
			||||||
 | 
					            "If you publish or delete this event, " +
 | 
				
			||||||
 | 
					            "it will also be published (or deleted) for the following weeks.",
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        [nbEvents],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }));
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@@ -0,0 +1,67 @@
 | 
				
			|||||||
 | 
					import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
 | 
				
			||||||
 | 
					  // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
				
			||||||
 | 
					  start_date: Date;
 | 
				
			||||||
 | 
					  // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
				
			||||||
 | 
					  end_date: Date;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					document.addEventListener("alpine:init", () => {
 | 
				
			||||||
 | 
					  Alpine.data("upcomingNewsLoader", (startDate: Date) => ({
 | 
				
			||||||
 | 
					    startDate: startDate,
 | 
				
			||||||
 | 
					    currentPage: 1,
 | 
				
			||||||
 | 
					    pageSize: 6,
 | 
				
			||||||
 | 
					    hasNext: true,
 | 
				
			||||||
 | 
					    loading: false,
 | 
				
			||||||
 | 
					    newsDates: [] as NewsDateSchema[],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async loadMore() {
 | 
				
			||||||
 | 
					      this.loading = true;
 | 
				
			||||||
 | 
					      const response = await newsFetchNewsDates({
 | 
				
			||||||
 | 
					        query: {
 | 
				
			||||||
 | 
					          after: this.startDate.toISOString(),
 | 
				
			||||||
 | 
					          // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
				
			||||||
 | 
					          text_format: "html",
 | 
				
			||||||
 | 
					          page: this.currentPage,
 | 
				
			||||||
 | 
					          // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
				
			||||||
 | 
					          page_size: this.pageSize,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      if (response.response.status === 404) {
 | 
				
			||||||
 | 
					        this.hasNext = false;
 | 
				
			||||||
 | 
					      } else if (response.data.next === null) {
 | 
				
			||||||
 | 
					        this.newsDates.push(...response.data.results);
 | 
				
			||||||
 | 
					        this.hasNext = false;
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        this.newsDates.push(...response.data.results);
 | 
				
			||||||
 | 
					        this.currentPage += 1;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.loading = false;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    groupedDates(): Record<string, NewsDateSchema[]> {
 | 
				
			||||||
 | 
					      return this.newsDates
 | 
				
			||||||
 | 
					        .map(
 | 
				
			||||||
 | 
					          (date: NewsDateSchema): ParsedNewsDateSchema => ({
 | 
				
			||||||
 | 
					            ...date,
 | 
				
			||||||
 | 
					            // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
				
			||||||
 | 
					            start_date: new Date(date.start_date),
 | 
				
			||||||
 | 
					            // biome-ignore lint/style/useNamingConvention: api is snake_case
 | 
				
			||||||
 | 
					            end_date: new Date(date.end_date),
 | 
				
			||||||
 | 
					          }),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .reduce(
 | 
				
			||||||
 | 
					          (acc: Record<string, ParsedNewsDateSchema[]>, date: ParsedNewsDateSchema) => {
 | 
				
			||||||
 | 
					            const key = date.start_date.toDateString();
 | 
				
			||||||
 | 
					            if (!acc[key]) {
 | 
				
			||||||
 | 
					              acc[key] = [];
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            acc[key].push(date);
 | 
				
			||||||
 | 
					            return acc;
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {},
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }));
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@@ -75,10 +75,10 @@ ics-calendar {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  td {
 | 
					  td {
 | 
				
			||||||
    overflow-x: visible; // Show events on multiple days
 | 
					    overflow: visible; // Show events on multiple days
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	//Reset from style.scss
 | 
					  //Reset from style.scss
 | 
				
			||||||
  table {
 | 
					  table {
 | 
				
			||||||
    box-shadow: none;
 | 
					    box-shadow: none;
 | 
				
			||||||
    border-radius: 0px;
 | 
					    border-radius: 0px;
 | 
				
			||||||
@@ -86,13 +86,13 @@ ics-calendar {
 | 
				
			|||||||
    margin: 0px;
 | 
					    margin: 0px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Reset from style.scss
 | 
					  // Reset from style.scss
 | 
				
			||||||
  thead {
 | 
					  thead {
 | 
				
			||||||
    background-color: white;
 | 
					    background-color: white;
 | 
				
			||||||
    color: black;
 | 
					    color: black;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Reset from style.scss
 | 
					  // Reset from style.scss
 | 
				
			||||||
  tbody>tr {
 | 
					  tbody>tr {
 | 
				
			||||||
    &:nth-child(even):not(.highlight) {
 | 
					    &:nth-child(even):not(.highlight) {
 | 
				
			||||||
      background: white;
 | 
					      background: white;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -36,6 +36,11 @@
 | 
				
			|||||||
    &:not(:first-of-type) {
 | 
					    &:not(:first-of-type) {
 | 
				
			||||||
      margin: 2em 0 1em 0;
 | 
					      margin: 2em 0 1em 0;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .feed {
 | 
				
			||||||
 | 
					      float: right;
 | 
				
			||||||
 | 
					      color: #f26522;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @media screen and (max-width: $small-devices) {
 | 
					  @media screen and (max-width: $small-devices) {
 | 
				
			||||||
@@ -46,6 +51,20 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* UPCOMING EVENTS */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  #upcoming-events {
 | 
				
			||||||
 | 
					    max-height: 600px;
 | 
				
			||||||
 | 
					    overflow-y: scroll;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #load-more-news-button {
 | 
				
			||||||
 | 
					      text-align: center;
 | 
				
			||||||
 | 
					      button {
 | 
				
			||||||
 | 
					        width: 150px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /* LINKS/BIRTHDAYS */
 | 
					  /* LINKS/BIRTHDAYS */
 | 
				
			||||||
  #links,
 | 
					  #links,
 | 
				
			||||||
  #birthdays {
 | 
					  #birthdays {
 | 
				
			||||||
@@ -166,54 +185,24 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      .news_event {
 | 
					      .news_event {
 | 
				
			||||||
        display: block;
 | 
					        display: flex;
 | 
				
			||||||
        padding: 0.4em;
 | 
					        flex-direction: column;
 | 
				
			||||||
 | 
					        gap: .5em;
 | 
				
			||||||
        &:not(:last-child) {
 | 
					        padding: 1em;
 | 
				
			||||||
          border-bottom: 1px solid grey;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        div {
 | 
					 | 
				
			||||||
          margin: 0.2em;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        h4 {
 | 
					 | 
				
			||||||
          margin-top: 1em;
 | 
					 | 
				
			||||||
          text-transform: uppercase;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        .club_logo {
 | 
					 | 
				
			||||||
          float: left;
 | 
					 | 
				
			||||||
          min-width: 7em;
 | 
					 | 
				
			||||||
          max-width: 9em;
 | 
					 | 
				
			||||||
          margin: 0;
 | 
					 | 
				
			||||||
          margin-right: 1em;
 | 
					 | 
				
			||||||
          margin-top: 0.8em;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        header {
 | 
				
			||||||
          img {
 | 
					          img {
 | 
				
			||||||
            max-height: 6em;
 | 
					            height: 75px;
 | 
				
			||||||
            max-width: 8em;
 | 
					 | 
				
			||||||
            display: block;
 | 
					 | 
				
			||||||
            margin: 0 auto;
 | 
					 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					          .header_content {
 | 
				
			||||||
 | 
					            display: flex;
 | 
				
			||||||
 | 
					            flex-direction: column;
 | 
				
			||||||
 | 
					            justify-content: center;
 | 
				
			||||||
 | 
					            gap: .2rem;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        .news_date {
 | 
					            h4 {
 | 
				
			||||||
          font-size: 100%;
 | 
					              margin-top: 0;
 | 
				
			||||||
        }
 | 
					              text-transform: uppercase;
 | 
				
			||||||
 | 
					 | 
				
			||||||
        .news_content {
 | 
					 | 
				
			||||||
          clear: left;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          .button_bar {
 | 
					 | 
				
			||||||
            text-align: right;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            .fb {
 | 
					 | 
				
			||||||
              color: $faceblue;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            .twitter {
 | 
					 | 
				
			||||||
              color: $twitblue;
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -223,70 +212,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  /* END EVENTS TODAY AND NEXT FEW DAYS */
 | 
					  /* END EVENTS TODAY AND NEXT FEW DAYS */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /* COMING SOON */
 | 
					 | 
				
			||||||
  .news_coming_soon {
 | 
					 | 
				
			||||||
    display: list-item;
 | 
					 | 
				
			||||||
    list-style-type: square;
 | 
					 | 
				
			||||||
    list-style-position: inside;
 | 
					 | 
				
			||||||
    margin-left: 1em;
 | 
					 | 
				
			||||||
    padding-left: 0;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    a {
 | 
					 | 
				
			||||||
      font-weight: bold;
 | 
					 | 
				
			||||||
      text-transform: uppercase;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .news_date {
 | 
					 | 
				
			||||||
      font-size: 0.9em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* END COMING SOON */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* NOTICES */
 | 
					 | 
				
			||||||
  .news_notice {
 | 
					 | 
				
			||||||
    margin: 0 0 1em 1em;
 | 
					 | 
				
			||||||
    padding: 0.4em;
 | 
					 | 
				
			||||||
    padding-left: 1em;
 | 
					 | 
				
			||||||
    background: $secondary-neutral-light-color;
 | 
					 | 
				
			||||||
    box-shadow: $shadow-color 0 0 2px;
 | 
					 | 
				
			||||||
    border-radius: 18px 5px 18px 5px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    h4 {
 | 
					 | 
				
			||||||
      margin: 0;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .news_content {
 | 
					 | 
				
			||||||
      margin-left: 1em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* END NOTICES */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* CALLS */
 | 
					 | 
				
			||||||
  .news_call {
 | 
					 | 
				
			||||||
    margin: 0 0 1em 1em;
 | 
					 | 
				
			||||||
    padding: 0.4em;
 | 
					 | 
				
			||||||
    padding-left: 1em;
 | 
					 | 
				
			||||||
    background: $secondary-neutral-light-color;
 | 
					 | 
				
			||||||
    border: 1px solid grey;
 | 
					 | 
				
			||||||
    box-shadow: $shadow-color 1px 1px 1px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    h4 {
 | 
					 | 
				
			||||||
      margin: 0;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .news_date {
 | 
					 | 
				
			||||||
      font-size: 0.9em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .news_content {
 | 
					 | 
				
			||||||
      margin-left: 1em;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /* END CALLS */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .news_empty {
 | 
					  .news_empty {
 | 
				
			||||||
    margin-left: 1em;
 | 
					    margin-left: 1em;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										127
									
								
								com/templates/com/macros.jinja
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								com/templates/com/macros.jinja
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,127 @@
 | 
				
			|||||||
 | 
					{% macro news_moderation_alert(news, user, alpineState = None) %}
 | 
				
			||||||
 | 
					    {# An alert to display on top of unpublished news,
 | 
				
			||||||
 | 
					    with actions to either publish or delete them.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    The current state of the alert is accessible through
 | 
				
			||||||
 | 
					    the given `alpineState` variable.
 | 
				
			||||||
 | 
					    This state is a `AlertState`, as defined in `moderation-alert-index.ts`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This comes in three flavours :
 | 
				
			||||||
 | 
					    - You can pass the `News` object itself to the macro.
 | 
				
			||||||
 | 
					      In this case, if `request.user` can publish news,
 | 
				
			||||||
 | 
					      it will perform an additional db query to know if it is a recurring event.
 | 
				
			||||||
 | 
					    - You can also give only the news id.
 | 
				
			||||||
 | 
					      In this case, a server request will be issued to know
 | 
				
			||||||
 | 
					      if it is a recurring event.
 | 
				
			||||||
 | 
					    - Finally, you can pass the name of an alpine variable, which value is the id.
 | 
				
			||||||
 | 
					      In this case, a server request will be issued to know
 | 
				
			||||||
 | 
					      if it is a recurring event.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Example with full `News` object :
 | 
				
			||||||
 | 
					    ```jinja
 | 
				
			||||||
 | 
					    <div x-data="{state: AlertState.PENDING}">
 | 
				
			||||||
 | 
					      {{ news_moderation_alert(news, user, "state") }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					    With an id :
 | 
				
			||||||
 | 
					    ```jinja
 | 
				
			||||||
 | 
					    <div x-data="{state: AlertState.PENDING}">
 | 
				
			||||||
 | 
					      {{ news_moderation_alert(news.id, user, "state") }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					    An with an alpine variable
 | 
				
			||||||
 | 
					    ```jinja
 | 
				
			||||||
 | 
					    <div x-data="{state: AlertState.PENDING, newsId: {{ news.id }}">
 | 
				
			||||||
 | 
					      {{ news_moderation_alert("newsId", user, "state") }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        news: (News | int | string)
 | 
				
			||||||
 | 
					          Either the `News` object to which this alert is related,
 | 
				
			||||||
 | 
					          or its id, or the name of an Alpine which value is its id
 | 
				
			||||||
 | 
					        user: The request.user
 | 
				
			||||||
 | 
					        alpineState: An alpine variable name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Warning:
 | 
				
			||||||
 | 
					        If you use this macro, you must also include `moderation-alert-index.ts`
 | 
				
			||||||
 | 
					        in your template.
 | 
				
			||||||
 | 
					    #}
 | 
				
			||||||
 | 
					  <div
 | 
				
			||||||
 | 
					    {% if news is integer or news is string %}
 | 
				
			||||||
 | 
					      x-data="moderationAlert({{ news }})"
 | 
				
			||||||
 | 
					    {% else %}
 | 
				
			||||||
 | 
					      x-data="moderationAlert({{ news.id }})"
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					    {# the news-moderated is received when a moderation alert is deleted or moderated #}
 | 
				
			||||||
 | 
					    @news-moderated.window="dispatchModeration($event)"
 | 
				
			||||||
 | 
					    {% if alpineState %}
 | 
				
			||||||
 | 
					      x-model="{{ alpineState }}"
 | 
				
			||||||
 | 
					      x-modelable="state"
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <template x-if="state === AlertState.PENDING">
 | 
				
			||||||
 | 
					      <div class="alert alert-yellow">
 | 
				
			||||||
 | 
					        <div class="alert-main">
 | 
				
			||||||
 | 
					          <strong>{% trans %}Waiting publication{% endtrans %}</strong>
 | 
				
			||||||
 | 
					          <p>
 | 
				
			||||||
 | 
					            {% trans trimmed %}
 | 
				
			||||||
 | 
					              This news isn't published and is visible
 | 
				
			||||||
 | 
					              only by its author and the communication admins.
 | 
				
			||||||
 | 
					            {% endtrans %}
 | 
				
			||||||
 | 
					          </p>
 | 
				
			||||||
 | 
					          <p>
 | 
				
			||||||
 | 
					            {% trans trimmed %}
 | 
				
			||||||
 | 
					              It will stay hidden for other users until it has been published.
 | 
				
			||||||
 | 
					            {% endtrans %}
 | 
				
			||||||
 | 
					          </p>
 | 
				
			||||||
 | 
					          {% if user.has_perm("com.moderate_news") %}
 | 
				
			||||||
 | 
					            {# This is an additional query for each non-moderated news,
 | 
				
			||||||
 | 
					            but it will be executed only for admin users, and only one time
 | 
				
			||||||
 | 
					            (if they do their job and moderated news as soon as they see them),
 | 
				
			||||||
 | 
					            so it's still reasonable #}
 | 
				
			||||||
 | 
					            <div
 | 
				
			||||||
 | 
					              {% if news is integer or news is string %}
 | 
				
			||||||
 | 
					                x-data="{ nbEvents: 0 }"
 | 
				
			||||||
 | 
					                x-init="nbEvents = await nbToPublish()"
 | 
				
			||||||
 | 
					              {% else %}
 | 
				
			||||||
 | 
					                x-data="{ nbEvents: {{ news.dates.count() }} }"
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <template x-if="nbEvents > 1">
 | 
				
			||||||
 | 
					                <div>
 | 
				
			||||||
 | 
					                  <br>
 | 
				
			||||||
 | 
					                  <strong>{% trans %}Weekly event{% endtrans %}</strong>
 | 
				
			||||||
 | 
					                  <p x-text="weeklyEventWarningMessage(nbEvents)"></p>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </template>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        {% if user.has_perm("com.moderate_news") %}
 | 
				
			||||||
 | 
					          <span class="alert-aside" :aria-busy="loading">
 | 
				
			||||||
 | 
					            <button class="btn btn-green" @click="publishNews()" :disabled="loading">
 | 
				
			||||||
 | 
					              <i class="fa fa-check"></i> {% trans %}Publish{% endtrans %}
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					        {% if user.has_perm("com.delete_news") %}
 | 
				
			||||||
 | 
					          <button class="btn btn-red" @click="deleteNews()" :disabled="loading">
 | 
				
			||||||
 | 
					            <i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					    <template x-if="state === AlertState.PUBLISHED">
 | 
				
			||||||
 | 
					      <div class="alert alert-green">
 | 
				
			||||||
 | 
					        {% trans %}News published{% endtrans %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					    <template x-if="state === AlertState.DELETED">
 | 
				
			||||||
 | 
					      <div class="alert alert-red">
 | 
				
			||||||
 | 
					        {% trans %}News deleted{% endtrans %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </template>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					{% endmacro %}
 | 
				
			||||||
@@ -10,78 +10,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  <p><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></p>
 | 
					  <p><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <hr />
 | 
					 | 
				
			||||||
  <h4>{% trans %}Notices{% endtrans %}</h4>
 | 
					 | 
				
			||||||
  {% set notices = object_list.filter(type="NOTICE").distinct().order_by('id') %}
 | 
					 | 
				
			||||||
  <h5>{% trans %}Displayed notices{% endtrans %}</h5>
 | 
					 | 
				
			||||||
  <table>
 | 
					 | 
				
			||||||
    <thead>
 | 
					 | 
				
			||||||
      <tr>
 | 
					 | 
				
			||||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Author{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Moderator{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Actions{% endtrans %}</td>
 | 
					 | 
				
			||||||
      </tr>
 | 
					 | 
				
			||||||
    </thead>
 | 
					 | 
				
			||||||
    <tbody>
 | 
					 | 
				
			||||||
      {% for news in notices.filter(is_moderated=True) %}
 | 
					 | 
				
			||||||
        <tr>
 | 
					 | 
				
			||||||
          <td>{{ news.get_type_display() }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.title }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.summary|markdown }}</td>
 | 
					 | 
				
			||||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
					 | 
				
			||||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
					 | 
				
			||||||
          <td>{{ user_profile_link(news.moderator) }}</td>
 | 
					 | 
				
			||||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
					 | 
				
			||||||
          </td>
 | 
					 | 
				
			||||||
        </tr>
 | 
					 | 
				
			||||||
      {% endfor %}
 | 
					 | 
				
			||||||
    </tbody>
 | 
					 | 
				
			||||||
  </table>
 | 
					 | 
				
			||||||
  <h5>{% trans %}Notices to moderate{% endtrans %}</h5>
 | 
					 | 
				
			||||||
  <table>
 | 
					 | 
				
			||||||
    <thead>
 | 
					 | 
				
			||||||
      <tr>
 | 
					 | 
				
			||||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Author{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Actions{% endtrans %}</td>
 | 
					 | 
				
			||||||
      </tr>
 | 
					 | 
				
			||||||
    </thead>
 | 
					 | 
				
			||||||
    <tbody>
 | 
					 | 
				
			||||||
      {% for news in notices.filter(is_moderated=False) %}
 | 
					 | 
				
			||||||
        <tr>
 | 
					 | 
				
			||||||
          <td>{{ news.get_type_display() }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.title }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.summary|markdown }}</td>
 | 
					 | 
				
			||||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
					 | 
				
			||||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
					 | 
				
			||||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
					 | 
				
			||||||
          </td>
 | 
					 | 
				
			||||||
        </tr>
 | 
					 | 
				
			||||||
      {% endfor %}
 | 
					 | 
				
			||||||
    </tbody>
 | 
					 | 
				
			||||||
  </table>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <hr />
 | 
					  <hr />
 | 
				
			||||||
  <h4>{% trans %}Weeklies{% endtrans %}</h4>
 | 
					  <h4>{% trans %}Weeklies{% endtrans %}</h4>
 | 
				
			||||||
  {% set weeklies = object_list.filter(type="WEEKLY", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
 | 
					  {% set weeklies = object_list.filter(dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
 | 
				
			||||||
  <h5>{% trans %}Displayed weeklies{% endtrans %}</h5>
 | 
					  <h5>{% trans %}Displayed weeklies{% endtrans %}</h5>
 | 
				
			||||||
  <table>
 | 
					  <table>
 | 
				
			||||||
    <thead>
 | 
					    <thead>
 | 
				
			||||||
      <tr>
 | 
					      <tr>
 | 
				
			||||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
					        <td>{% trans %}Title{% endtrans %}</td>
 | 
				
			||||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
					        <td>{% trans %}Summary{% endtrans %}</td>
 | 
				
			||||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
					        <td>{% trans %}Club{% endtrans %}</td>
 | 
				
			||||||
@@ -92,9 +27,8 @@
 | 
				
			|||||||
      </tr>
 | 
					      </tr>
 | 
				
			||||||
    </thead>
 | 
					    </thead>
 | 
				
			||||||
    <tbody>
 | 
					    <tbody>
 | 
				
			||||||
      {% for news in weeklies.filter(is_moderated=True) %}
 | 
					      {% for news in weeklies.filter(is_published=True) %}
 | 
				
			||||||
        <tr>
 | 
					        <tr>
 | 
				
			||||||
          <td>{{ news.get_type_display() }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.title }}</td>
 | 
					          <td>{{ news.title }}</td>
 | 
				
			||||||
          <td>{{ news.summary|markdown }}</td>
 | 
					          <td>{{ news.summary|markdown }}</td>
 | 
				
			||||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
					          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
				
			||||||
@@ -113,7 +47,7 @@
 | 
				
			|||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
					          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
				
			||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
        </tr>
 | 
					        </tr>
 | 
				
			||||||
@@ -124,7 +58,6 @@
 | 
				
			|||||||
  <table>
 | 
					  <table>
 | 
				
			||||||
    <thead>
 | 
					    <thead>
 | 
				
			||||||
      <tr>
 | 
					      <tr>
 | 
				
			||||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
					        <td>{% trans %}Title{% endtrans %}</td>
 | 
				
			||||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
					        <td>{% trans %}Summary{% endtrans %}</td>
 | 
				
			||||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
					        <td>{% trans %}Club{% endtrans %}</td>
 | 
				
			||||||
@@ -134,9 +67,8 @@
 | 
				
			|||||||
      </tr>
 | 
					      </tr>
 | 
				
			||||||
    </thead>
 | 
					    </thead>
 | 
				
			||||||
    <tbody>
 | 
					    <tbody>
 | 
				
			||||||
      {% for news in weeklies.filter(is_moderated=False) %}
 | 
					      {% for news in weeklies.filter(is_published=False) %}
 | 
				
			||||||
        <tr>
 | 
					        <tr>
 | 
				
			||||||
          <td>{{ news.get_type_display() }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.title }}</td>
 | 
					          <td>{{ news.title }}</td>
 | 
				
			||||||
          <td>{{ news.summary|markdown }}</td>
 | 
					          <td>{{ news.summary|markdown }}</td>
 | 
				
			||||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
					          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
				
			||||||
@@ -154,98 +86,20 @@
 | 
				
			|||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
					          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
				
			||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
        </tr>
 | 
					        </tr>
 | 
				
			||||||
      {% endfor %}
 | 
					      {% endfor %}
 | 
				
			||||||
    </tbody>
 | 
					    </tbody>
 | 
				
			||||||
  </table>
 | 
					  </table>
 | 
				
			||||||
 | 
					 | 
				
			||||||
  <hr />
 | 
					 | 
				
			||||||
  <h4>{% trans %}Calls{% endtrans %}</h4>
 | 
					 | 
				
			||||||
  {% set calls = object_list.filter(type="CALL", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
 | 
					 | 
				
			||||||
  <h5>{% trans %}Displayed calls{% endtrans %}</h5>
 | 
					 | 
				
			||||||
  <table>
 | 
					 | 
				
			||||||
    <thead>
 | 
					 | 
				
			||||||
      <tr>
 | 
					 | 
				
			||||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Author{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Moderator{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Start{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}End{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Actions{% endtrans %}</td>
 | 
					 | 
				
			||||||
      </tr>
 | 
					 | 
				
			||||||
    </thead>
 | 
					 | 
				
			||||||
    <tbody>
 | 
					 | 
				
			||||||
      {% for news in calls.filter(is_moderated=True) %}
 | 
					 | 
				
			||||||
        <tr>
 | 
					 | 
				
			||||||
          <td>{{ news.get_type_display() }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.title }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.summary|markdown }}</td>
 | 
					 | 
				
			||||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
					 | 
				
			||||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
					 | 
				
			||||||
          <td>{{ user_profile_link(news.moderator) }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
					 | 
				
			||||||
            {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
					 | 
				
			||||||
            {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
					 | 
				
			||||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
					 | 
				
			||||||
          </td>
 | 
					 | 
				
			||||||
        </tr>
 | 
					 | 
				
			||||||
      {% endfor %}
 | 
					 | 
				
			||||||
    </tbody>
 | 
					 | 
				
			||||||
  </table>
 | 
					 | 
				
			||||||
  <h5>{% trans %}Calls to moderate{% endtrans %}</h5>
 | 
					 | 
				
			||||||
  <table>
 | 
					 | 
				
			||||||
    <thead>
 | 
					 | 
				
			||||||
      <tr>
 | 
					 | 
				
			||||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Author{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Start{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}End{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Actions{% endtrans %}</td>
 | 
					 | 
				
			||||||
      </tr>
 | 
					 | 
				
			||||||
    </thead>
 | 
					 | 
				
			||||||
    <tbody>
 | 
					 | 
				
			||||||
      {% for news in calls.filter(is_moderated=False) %}
 | 
					 | 
				
			||||||
        <tr>
 | 
					 | 
				
			||||||
          <td>{{ news.get_type_display() }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.title }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.summary|markdown }}</td>
 | 
					 | 
				
			||||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
					 | 
				
			||||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
					 | 
				
			||||||
            {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
					 | 
				
			||||||
            {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
					 | 
				
			||||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
 | 
					 | 
				
			||||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
					 | 
				
			||||||
          </td>
 | 
					 | 
				
			||||||
        </tr>
 | 
					 | 
				
			||||||
      {% endfor %}
 | 
					 | 
				
			||||||
    </tbody>
 | 
					 | 
				
			||||||
  </table>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <hr />
 | 
					  <hr />
 | 
				
			||||||
  <h4>{% trans %}Events{% endtrans %}</h4>
 | 
					  <h4>{% trans %}Events{% endtrans %}</h4>
 | 
				
			||||||
  {% set events = object_list.filter(type="EVENT", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
 | 
					  {% set events = object_list.filter(dates__end_date__gte=timezone.now()).order_by('id') %}
 | 
				
			||||||
  <h5>{% trans %}Displayed events{% endtrans %}</h5>
 | 
					  <h5>{% trans %}Displayed events{% endtrans %}</h5>
 | 
				
			||||||
  <table>
 | 
					  <table>
 | 
				
			||||||
    <thead>
 | 
					    <thead>
 | 
				
			||||||
      <tr>
 | 
					      <tr>
 | 
				
			||||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
					        <td>{% trans %}Title{% endtrans %}</td>
 | 
				
			||||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
					        <td>{% trans %}Summary{% endtrans %}</td>
 | 
				
			||||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
					        <td>{% trans %}Club{% endtrans %}</td>
 | 
				
			||||||
@@ -257,21 +111,20 @@
 | 
				
			|||||||
      </tr>
 | 
					      </tr>
 | 
				
			||||||
    </thead>
 | 
					    </thead>
 | 
				
			||||||
    <tbody>
 | 
					    <tbody>
 | 
				
			||||||
      {% for news in events.filter(is_moderated=True) %}
 | 
					      {% for news in events.filter(is_published=True) %}
 | 
				
			||||||
        <tr>
 | 
					        <tr>
 | 
				
			||||||
          <td>{{ news.get_type_display() }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.title }}</td>
 | 
					          <td>{{ news.title }}</td>
 | 
				
			||||||
          <td>{{ news.summary|markdown }}</td>
 | 
					          <td>{{ news.summary|markdown }}</td>
 | 
				
			||||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
					          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
				
			||||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
					          <td>{{ user_profile_link(news.author) }}</td>
 | 
				
			||||||
          <td>{{ user_profile_link(news.moderator) }}</td>
 | 
					          <td>{{ user_profile_link(news.moderator) }}</td>
 | 
				
			||||||
          <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
					          <td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
 | 
				
			||||||
            {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
					            {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
				
			||||||
          <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
					          <td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
 | 
				
			||||||
            {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
					            {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
				
			||||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
					          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
				
			||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
        </tr>
 | 
					        </tr>
 | 
				
			||||||
@@ -282,7 +135,6 @@
 | 
				
			|||||||
  <table>
 | 
					  <table>
 | 
				
			||||||
    <thead>
 | 
					    <thead>
 | 
				
			||||||
      <tr>
 | 
					      <tr>
 | 
				
			||||||
        <td>{% trans %}Type{% endtrans %}</td>
 | 
					 | 
				
			||||||
        <td>{% trans %}Title{% endtrans %}</td>
 | 
					        <td>{% trans %}Title{% endtrans %}</td>
 | 
				
			||||||
        <td>{% trans %}Summary{% endtrans %}</td>
 | 
					        <td>{% trans %}Summary{% endtrans %}</td>
 | 
				
			||||||
        <td>{% trans %}Club{% endtrans %}</td>
 | 
					        <td>{% trans %}Club{% endtrans %}</td>
 | 
				
			||||||
@@ -293,20 +145,19 @@
 | 
				
			|||||||
      </tr>
 | 
					      </tr>
 | 
				
			||||||
    </thead>
 | 
					    </thead>
 | 
				
			||||||
    <tbody>
 | 
					    <tbody>
 | 
				
			||||||
      {% for news in events.filter(is_moderated=False) %}
 | 
					      {% for news in events.filter(is_published=False) %}
 | 
				
			||||||
        <tr>
 | 
					        <tr>
 | 
				
			||||||
          <td>{{ news.get_type_display() }}</td>
 | 
					 | 
				
			||||||
          <td>{{ news.title }}</td>
 | 
					          <td>{{ news.title }}</td>
 | 
				
			||||||
          <td>{{ news.summary|markdown }}</td>
 | 
					          <td>{{ news.summary|markdown }}</td>
 | 
				
			||||||
          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
					          <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
 | 
				
			||||||
          <td>{{ user_profile_link(news.author) }}</td>
 | 
					          <td>{{ user_profile_link(news.author) }}</td>
 | 
				
			||||||
          <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
					          <td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
 | 
				
			||||||
            {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
					            {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
				
			||||||
          <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
					          <td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
 | 
				
			||||||
            {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
					            {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
 | 
				
			||||||
          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
					          <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
 | 
				
			||||||
            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
					            <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
 | 
				
			||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
        </tr>
 | 
					        </tr>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
{% extends "core/base.jinja" %}
 | 
					{% extends "core/base.jinja" %}
 | 
				
			||||||
{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
 | 
					{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
 | 
				
			||||||
 | 
					{% from "com/macros.jinja" import news_moderation_alert %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block title %}
 | 
					{% block title %}
 | 
				
			||||||
  {% trans %}News{% endtrans %} -
 | 
					  {% trans %}News{% endtrans %} -
 | 
				
			||||||
@@ -16,39 +17,49 @@
 | 
				
			|||||||
  <link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
 | 
					  <link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block additional_js %}
 | 
				
			||||||
 | 
					  <script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
 | 
					  <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
 | 
				
			||||||
  <section id="news_details">
 | 
					  <div x-data="{newsState: AlertState.PENDING}">
 | 
				
			||||||
    <div class="club_logo">
 | 
					
 | 
				
			||||||
      <img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
 | 
					    {% if not news.is_published %}
 | 
				
			||||||
      <a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
 | 
					      {{ news_moderation_alert(news, user, "newsState") }}
 | 
				
			||||||
    </div>
 | 
					    {% endif %}
 | 
				
			||||||
    <h4>{{ news.title }}</h4>
 | 
					    <article id="news_details" x-show="newsState !== AlertState.DELETED">
 | 
				
			||||||
    <p class="date">
 | 
					      <div class="club_logo">
 | 
				
			||||||
      <span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
					        <img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
 | 
				
			||||||
        {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
					        <a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
 | 
				
			||||||
      <span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
					 | 
				
			||||||
        {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    <div class="news_content">
 | 
					 | 
				
			||||||
      <div><em>{{ news.summary|markdown }}</em></div>
 | 
					 | 
				
			||||||
      <br/>
 | 
					 | 
				
			||||||
      <div>{{ news.content|markdown }}</div>
 | 
					 | 
				
			||||||
      {{ facebook_share(news) }}
 | 
					 | 
				
			||||||
      {{ tweet(news) }}
 | 
					 | 
				
			||||||
      <div class="news_meta">
 | 
					 | 
				
			||||||
        <p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
 | 
					 | 
				
			||||||
        {% if news.moderator %}
 | 
					 | 
				
			||||||
          <p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
 | 
					 | 
				
			||||||
        {% elif user.is_com_admin %}
 | 
					 | 
				
			||||||
          <p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a></p>
 | 
					 | 
				
			||||||
        {% endif %}
 | 
					 | 
				
			||||||
        {% if user.can_edit(news) %}
 | 
					 | 
				
			||||||
          <p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
 | 
					 | 
				
			||||||
        {% endif %}
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					      <h4>{{ news.title }}</h4>
 | 
				
			||||||
  </section>
 | 
					      <p class="date">
 | 
				
			||||||
 | 
					        <time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
 | 
				
			||||||
 | 
					          {{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
 | 
				
			||||||
 | 
					        <time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
 | 
				
			||||||
 | 
					          {{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
 | 
				
			||||||
 | 
					      </p>
 | 
				
			||||||
 | 
					      <div class="news_content">
 | 
				
			||||||
 | 
					        <div><em>{{ news.summary|markdown }}</em></div>
 | 
				
			||||||
 | 
					        <br/>
 | 
				
			||||||
 | 
					        <div>{{ news.content|markdown }}</div>
 | 
				
			||||||
 | 
					        {{ facebook_share(news) }}
 | 
				
			||||||
 | 
					        {{ tweet(news) }}
 | 
				
			||||||
 | 
					        <div class="news_meta">
 | 
				
			||||||
 | 
					          <p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
 | 
				
			||||||
 | 
					          {% if news.moderator %}
 | 
				
			||||||
 | 
					            <p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
 | 
				
			||||||
 | 
					          {% elif user.is_com_admin %}
 | 
				
			||||||
 | 
					            <p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a></p>
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					          {% if user.can_edit(news) %}
 | 
				
			||||||
 | 
					            <p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </article>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,21 +10,6 @@
 | 
				
			|||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  {% if 'preview' in request.POST.keys() %}
 | 
					 | 
				
			||||||
    <section class="news_event">
 | 
					 | 
				
			||||||
      <h4>{{ form.instance.title }}</h4>
 | 
					 | 
				
			||||||
      <p class="date">
 | 
					 | 
				
			||||||
        <span>{{ form.instance.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
					 | 
				
			||||||
          {{ form.instance.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
					 | 
				
			||||||
        <span>{{ form.instance.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
					 | 
				
			||||||
          {{ form.instance.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
					 | 
				
			||||||
      </p>
 | 
					 | 
				
			||||||
      <p><a href="#">{{ form.instance.club or "Club" }}</a></p>
 | 
					 | 
				
			||||||
      <div>{{ form.instance.summary|markdown }}</div>
 | 
					 | 
				
			||||||
      <div>{{ form.instance.content|markdown }}</div>
 | 
					 | 
				
			||||||
      <p>{% trans %}Author: {% endtrans %} {{ user_profile_link(form.instance.author) }}</p>
 | 
					 | 
				
			||||||
    </section>
 | 
					 | 
				
			||||||
  {% endif %}
 | 
					 | 
				
			||||||
  {% if object %}
 | 
					  {% if object %}
 | 
				
			||||||
    <h2>{% trans %}Edit news{% endtrans %}</h2>
 | 
					    <h2>{% trans %}Edit news{% endtrans %}</h2>
 | 
				
			||||||
  {% else %}
 | 
					  {% else %}
 | 
				
			||||||
@@ -33,103 +18,73 @@
 | 
				
			|||||||
  <form action="" method="post">
 | 
					  <form action="" method="post">
 | 
				
			||||||
    {% csrf_token %}
 | 
					    {% csrf_token %}
 | 
				
			||||||
    {{ form.non_field_errors() }}
 | 
					    {{ form.non_field_errors() }}
 | 
				
			||||||
    {{ form.author }}
 | 
					    <fieldset>
 | 
				
			||||||
    <p>
 | 
					 | 
				
			||||||
      {{ form.type.errors }}
 | 
					 | 
				
			||||||
      <label for="{{ form.type.name }}" class="required">{{ form.type.label }}</label>
 | 
					 | 
				
			||||||
      <ul>
 | 
					 | 
				
			||||||
        <li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
 | 
					 | 
				
			||||||
        <li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
 | 
					 | 
				
			||||||
        <li>
 | 
					 | 
				
			||||||
          {% trans trimmed%}
 | 
					 | 
				
			||||||
            Weekly: recurrent event, associated with many dates
 | 
					 | 
				
			||||||
            (specify the first one, and a deadline)
 | 
					 | 
				
			||||||
          {% endtrans %}
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
        <li>
 | 
					 | 
				
			||||||
          {% trans trimmed %}
 | 
					 | 
				
			||||||
            Call: long time event, associated with a long date (like election appliance)
 | 
					 | 
				
			||||||
          {% endtrans %}
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
      </ul>
 | 
					 | 
				
			||||||
      {{ form.type }}
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    <p class="date">
 | 
					 | 
				
			||||||
      {{ form.start_date.errors }}
 | 
					 | 
				
			||||||
      <label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
 | 
					 | 
				
			||||||
      {{ form.start_date }}
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    <p class="date">
 | 
					 | 
				
			||||||
      {{ form.end_date.errors }}
 | 
					 | 
				
			||||||
      <label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label>
 | 
					 | 
				
			||||||
      {{ form.end_date }}
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    <p class="until">
 | 
					 | 
				
			||||||
      {{ form.until.errors }}
 | 
					 | 
				
			||||||
      <label for="{{ form.until.name }}">{{ form.until.label }}</label>
 | 
					 | 
				
			||||||
      {{ form.until }}
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
    <p>
 | 
					 | 
				
			||||||
      {{ form.title.errors }}
 | 
					      {{ form.title.errors }}
 | 
				
			||||||
      <label for="{{ form.title.name }}" class="required">{{ form.title.label }}</label>
 | 
					      {{ form.title.label_tag() }}
 | 
				
			||||||
      {{ form.title }}
 | 
					      {{ form.title }}
 | 
				
			||||||
    </p>
 | 
					    </fieldset>
 | 
				
			||||||
    <p>
 | 
					    <fieldset>
 | 
				
			||||||
      {{ form.club.errors }}
 | 
					      {{ form.club.errors }}
 | 
				
			||||||
      <label for="{{ form.club.name }}" class="required">{{ form.club.label }}</label>
 | 
					      {{ form.club.label_tag() }}
 | 
				
			||||||
      <span class="helptext">{{ form.club.help_text }}</span>
 | 
					      <span class="helptext">{{ form.club.help_text }}</span>
 | 
				
			||||||
      {{ form.club }}
 | 
					      {{ form.club }}
 | 
				
			||||||
    </p>
 | 
					    </fieldset>
 | 
				
			||||||
    <p>
 | 
					    {{ form.date_form.non_field_errors() }}
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      class="row gap-2x"
 | 
				
			||||||
 | 
					      x-data="{startDate: '{{ form.date_form.start_date.value() }}'}"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					        {# startDate is used to dynamically ensure end_date >= start_date,
 | 
				
			||||||
 | 
					        whatever the value of start_date #}
 | 
				
			||||||
 | 
					      <fieldset>
 | 
				
			||||||
 | 
					        {{ form.date_form.start_date.errors }}
 | 
				
			||||||
 | 
					        {{ form.date_form.start_date.label_tag() }}
 | 
				
			||||||
 | 
					        <span class="helptext">{{ form.date_form.start_date.help_text }}</span>
 | 
				
			||||||
 | 
					        {{ form.date_form.start_date|add_attr("x-model=startDate") }}
 | 
				
			||||||
 | 
					      </fieldset>
 | 
				
			||||||
 | 
					      <fieldset>
 | 
				
			||||||
 | 
					        {{ form.date_form.end_date.errors }}
 | 
				
			||||||
 | 
					        {{ form.date_form.end_date.label_tag() }}
 | 
				
			||||||
 | 
					        <span class="helptext">{{ form.date_form.end_date.help_text }}</span>
 | 
				
			||||||
 | 
					        {{ form.date_form.end_date|add_attr(":min=startDate") }}
 | 
				
			||||||
 | 
					      </fieldset>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    {# lower to convert True and False to true and false #}
 | 
				
			||||||
 | 
					    <div x-data="{isWeekly: {{ form.date_form.is_weekly.value()|lower }}}">
 | 
				
			||||||
 | 
					      <fieldset>
 | 
				
			||||||
 | 
					        <div class="row gap">
 | 
				
			||||||
 | 
					          {{ form.date_form.is_weekly|add_attr("x-model=isWeekly") }}
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            {{ form.date_form.is_weekly.label_tag() }}
 | 
				
			||||||
 | 
					            <span class="helptext">{{ form.date_form.is_weekly.help_text }}</span>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </fieldset>
 | 
				
			||||||
 | 
					      <fieldset x-show="isWeekly" x-transition x-cloak>
 | 
				
			||||||
 | 
					        {{ form.date_form.occurrences.label_tag() }}
 | 
				
			||||||
 | 
					        <span class="helptext">{{ form.date_form.occurrences.help_text }}</span>
 | 
				
			||||||
 | 
					        {{ form.date_form.occurrences }}
 | 
				
			||||||
 | 
					      </fieldset>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <fieldset>
 | 
				
			||||||
      {{ form.summary.errors }}
 | 
					      {{ form.summary.errors }}
 | 
				
			||||||
      <label for="{{ form.summary.name }}" class="required">{{ form.summary.label }}</label>
 | 
					      {{ form.summary.label_tag() }}
 | 
				
			||||||
      <span class="helptext">{{ form.summary.help_text }}</span>
 | 
					      <span class="helptext">{{ form.summary.help_text }}</span>
 | 
				
			||||||
      {{ form.summary }}
 | 
					      {{ form.summary }}
 | 
				
			||||||
    </p>
 | 
					    </fieldset>
 | 
				
			||||||
    <p>
 | 
					    <fieldset>
 | 
				
			||||||
      {{ form.content.errors }}
 | 
					      {{ form.content.errors }}
 | 
				
			||||||
      <label for="{{ form.content.name }}">{{ form.content.label }}</label>
 | 
					      {{ form.content.label_tag() }}
 | 
				
			||||||
      <span class="helptext">{{ form.content.help_text }}</span>
 | 
					      <span class="helptext">{{ form.content.help_text }}</span>
 | 
				
			||||||
      {{ form.content }}
 | 
					      {{ form.content }}
 | 
				
			||||||
    </p>
 | 
					    </fieldset>
 | 
				
			||||||
    {% if user.is_com_admin %}
 | 
					    {% if user.is_root or user.is_com_admin %}
 | 
				
			||||||
      <p>
 | 
					      <fieldset>
 | 
				
			||||||
        {{ form.automoderation.errors }}
 | 
					        {{ form.auto_publish.errors }}
 | 
				
			||||||
        <label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
 | 
					        {{ form.auto_publish }}
 | 
				
			||||||
        {{ form.automoderation }}
 | 
					        {{ form.auto_publish.label_tag() }}
 | 
				
			||||||
      </p>
 | 
					      </fieldset>
 | 
				
			||||||
    {% endif %}
 | 
					    {% endif %}
 | 
				
			||||||
    <p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
 | 
					    <p><input type="submit" value="{% trans %}Save{% endtrans %}" class="btn btn-blue"/></p>
 | 
				
			||||||
    <p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
 | 
					 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block script %}
 | 
					 | 
				
			||||||
  {{ super() }}
 | 
					 | 
				
			||||||
  <script>
 | 
					 | 
				
			||||||
    $(function () {
 | 
					 | 
				
			||||||
      let type = $('input[name=type]');
 | 
					 | 
				
			||||||
      let dates = $('.date');
 | 
					 | 
				
			||||||
      let until = $('.until');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      function update_targets() {
 | 
					 | 
				
			||||||
        const type_checked = $('input[name=type]:checked');
 | 
					 | 
				
			||||||
        if (["CALL", "EVENT"].includes(type_checked.val())) {
 | 
					 | 
				
			||||||
          dates.show();
 | 
					 | 
				
			||||||
          until.hide();
 | 
					 | 
				
			||||||
        } else if (type_checked.val() === "WEEKLY") {
 | 
					 | 
				
			||||||
          dates.show();
 | 
					 | 
				
			||||||
          until.show();
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          dates.hide();
 | 
					 | 
				
			||||||
          until.hide();
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      update_targets();
 | 
					 | 
				
			||||||
      type.change(update_targets);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  </script>
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
{% extends "core/base.jinja" %}
 | 
					{% extends "core/base.jinja" %}
 | 
				
			||||||
{% from 'core/macros.jinja' import tweet_quick, fb_quick %}
 | 
					{% from "com/macros.jinja" import news_moderation_alert %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block title %}
 | 
					{% block title %}
 | 
				
			||||||
  {% trans %}News{% endtrans %}
 | 
					  {% trans %}News{% endtrans %}
 | 
				
			||||||
@@ -8,162 +8,271 @@
 | 
				
			|||||||
{% block additional_css %}
 | 
					{% block additional_css %}
 | 
				
			||||||
  <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
 | 
					  <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
 | 
				
			||||||
  <link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
 | 
					  <link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  {# Atom feed discovery, not really css but also goes there #}
 | 
				
			||||||
 | 
					  <link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block additional_js %}
 | 
					{% block additional_js %}
 | 
				
			||||||
  <script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
 | 
					  <script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
 | 
				
			||||||
 | 
					  <script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
 | 
				
			||||||
 | 
					  <script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  {% if user.is_com_admin %}
 | 
					 | 
				
			||||||
    <div id="news_admin">
 | 
					 | 
				
			||||||
      <a class="button" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
    <br>
 | 
					 | 
				
			||||||
  {% endif  %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <div id="news">
 | 
					  <div id="news">
 | 
				
			||||||
    <div id="left_column" class="news_column">
 | 
					    <div id="left_column" class="news_column">
 | 
				
			||||||
      {% for news in object_list.filter(type="NOTICE") %}
 | 
					      <h3>
 | 
				
			||||||
        <section class="news_notice">
 | 
					        {% trans %}Events today and the next few days{% endtrans %}
 | 
				
			||||||
          <h4><a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
 | 
					        <a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
 | 
				
			||||||
          <div class="news_content">{{ news.summary|markdown }}</div>
 | 
					      </h3>
 | 
				
			||||||
        </section>
 | 
					      {% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
 | 
				
			||||||
      {% endfor %}
 | 
					        <a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
 | 
				
			||||||
 | 
					          <i class="fa fa-plus"></i>
 | 
				
			||||||
      {% for news in object_list.filter(dates__start_date__lte=timezone.now(), dates__end_date__gte=timezone.now(), type="CALL") %}
 | 
					          {% trans %}Create news{% endtrans %}
 | 
				
			||||||
        <section class="news_call">
 | 
					        </a>
 | 
				
			||||||
          <h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
 | 
					      {% endif %}
 | 
				
			||||||
          <div class="news_date">
 | 
					      {% if user.is_com_admin %}
 | 
				
			||||||
            <span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
 | 
					        <a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">
 | 
				
			||||||
              {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
					          {% trans %}Administrate news{% endtrans %}
 | 
				
			||||||
            <span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
 | 
					        </a>
 | 
				
			||||||
              {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
					        <br>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					      <section id="upcoming-events">
 | 
				
			||||||
 | 
					        {% if not news_dates %}
 | 
				
			||||||
 | 
					          <div class="news_empty">
 | 
				
			||||||
 | 
					            <em>{% trans %}Nothing to come...{% endtrans %}</em>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="news_content">{{ news.summary|markdown }}</div>
 | 
					        {% else %}
 | 
				
			||||||
        </section>
 | 
					          {% for day, dates_group in news_dates.items() %}
 | 
				
			||||||
      {% endfor %}
 | 
					            <div class="news_events_group">
 | 
				
			||||||
 | 
					              <div class="news_events_group_date">
 | 
				
			||||||
      {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__type="EVENT", news__is_moderated=True).datetimes('start_date', 'day') %}
 | 
					                <div>
 | 
				
			||||||
      <h3>{% trans %}Events today and the next few days{% endtrans %}</h3>
 | 
					                  <div>{{ day|date('D') }}</div>
 | 
				
			||||||
      {% if events_dates %}
 | 
					                  <div class="day">{{ day|date('d') }}</div>
 | 
				
			||||||
        {% for d in events_dates %}
 | 
					                  <div>{{ day|date('b') }}</div>
 | 
				
			||||||
          <div class="news_events_group">
 | 
					                </div>
 | 
				
			||||||
            <div class="news_events_group_date">
 | 
					              </div>
 | 
				
			||||||
              <div>
 | 
					              <div class="news_events_group_items">
 | 
				
			||||||
                <div>{{ d|localtime|date('D') }}</div>
 | 
					                {% for date in dates_group %}
 | 
				
			||||||
                <div class="day">{{ d|localtime|date('d') }}</div>
 | 
					                  <article
 | 
				
			||||||
                <div>{{ d|localtime|date('b') }}</div>
 | 
					                    class="news_event"
 | 
				
			||||||
 | 
					                    {%- if not date.news.is_published -%}
 | 
				
			||||||
 | 
					                      x-data="{newsState: AlertState.PENDING}"
 | 
				
			||||||
 | 
					                    {% else %}
 | 
				
			||||||
 | 
					                      x-data="{newsState: AlertState.DISPLAYED}"
 | 
				
			||||||
 | 
					                    {%- endif -%}
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                    {# if a non published news is in the object list,
 | 
				
			||||||
 | 
					                    the logged user is either an admin or the news author #}
 | 
				
			||||||
 | 
					                    {{ news_moderation_alert(date.news, user, "newsState") }}
 | 
				
			||||||
 | 
					                    <div
 | 
				
			||||||
 | 
					                      x-show="newsState !== AlertState.DELETED"
 | 
				
			||||||
 | 
					                    >
 | 
				
			||||||
 | 
					                      <header class="row gap">
 | 
				
			||||||
 | 
					                        {% if date.news.club.logo %}
 | 
				
			||||||
 | 
					                          <img src="{{ date.news.club.logo.url }}" alt="{{ date.news.club }}"/>
 | 
				
			||||||
 | 
					                        {% else %}
 | 
				
			||||||
 | 
					                          <img src="{{ static("com/img/news.png") }}" alt="{{ date.news.club }}"/>
 | 
				
			||||||
 | 
					                        {% endif %}
 | 
				
			||||||
 | 
					                        <div class="header_content">
 | 
				
			||||||
 | 
					                          <h4>
 | 
				
			||||||
 | 
					                            <a href="{{ url('com:news_detail', news_id=date.news_id) }}">
 | 
				
			||||||
 | 
					                              {{ date.news.title }}
 | 
				
			||||||
 | 
					                            </a>
 | 
				
			||||||
 | 
					                          </h4>
 | 
				
			||||||
 | 
					                          <a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
 | 
				
			||||||
 | 
					                          <div class="news_date">
 | 
				
			||||||
 | 
					                            <time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
 | 
				
			||||||
 | 
					                              {{ date.start_date|localtime|date(DATETIME_FORMAT) }}
 | 
				
			||||||
 | 
					                              {{ date.start_date|localtime|time(DATETIME_FORMAT) }}
 | 
				
			||||||
 | 
					                            </time> -
 | 
				
			||||||
 | 
					                            <time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
 | 
				
			||||||
 | 
					                              {{ date.end_date|localtime|date(DATETIME_FORMAT) }}
 | 
				
			||||||
 | 
					                              {{ date.end_date|localtime|time(DATETIME_FORMAT) }}
 | 
				
			||||||
 | 
					                            </time>
 | 
				
			||||||
 | 
					                          </div>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                      </header>
 | 
				
			||||||
 | 
					                      <div class="news_content markdown">
 | 
				
			||||||
 | 
					                        {{ date.news.summary|markdown }}
 | 
				
			||||||
 | 
					                      </div>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </article>
 | 
				
			||||||
 | 
					                {% endfor %}
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            <div class="news_events_group_items">
 | 
					          {% endfor %}
 | 
				
			||||||
              {% for news in object_list.filter(dates__start_date__gte=d,
 | 
					          <div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
 | 
				
			||||||
              dates__start_date__lte=d+timedelta(days=1),
 | 
					            <template x-for="newsList in Object.values(groupedDates())">
 | 
				
			||||||
              type="EVENT").exclude(dates__end_date__lt=timezone.now())
 | 
					              <div class="news_events_group">
 | 
				
			||||||
              .order_by('dates__start_date') %}
 | 
					                <div class="news_events_group_date">
 | 
				
			||||||
              <section class="news_event">
 | 
					                  <div x-data="{day: newsList[0].start_date}">
 | 
				
			||||||
                <div class="club_logo">
 | 
					                    <div x-text="day.toLocaleString('{{ get_language() }}', { weekday: 'short' }).substring(0, 3)"></div>
 | 
				
			||||||
                  {% if news.club.logo %}
 | 
					                    <div
 | 
				
			||||||
                    <img src="{{ news.club.logo.url }}" alt="{{ news.club }}" />
 | 
					                      class="day"
 | 
				
			||||||
                  {% else %}
 | 
					                      x-text="day.toLocaleString('{{ get_language() }}', { day: 'numeric' })"
 | 
				
			||||||
                    <img src="{{ static("com/img/news.png") }}" alt="{{ news.club }}" />
 | 
					                    ></div>
 | 
				
			||||||
                  {% endif %}
 | 
					                    <div x-text="day.toLocaleString('{{ get_language() }}', { month: 'short' }).substring(0, 3)"></div>
 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
 | 
					 | 
				
			||||||
                <div><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></div>
 | 
					 | 
				
			||||||
                <div class="news_date">
 | 
					 | 
				
			||||||
                  <span>{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
 | 
					 | 
				
			||||||
                  <span>{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div class="news_content">{{ news.summary|markdown }}
 | 
					 | 
				
			||||||
                  <div class="button_bar">
 | 
					 | 
				
			||||||
                    {{ fb_quick(news) }}
 | 
					 | 
				
			||||||
                    {{ tweet_quick(news) }}
 | 
					 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
              </section>
 | 
					                <div class="news_events_group_items">
 | 
				
			||||||
        {% endfor %}
 | 
					                  <template x-for="newsDate in newsList" :key="newsDate.id">
 | 
				
			||||||
        </div>
 | 
					                    <article
 | 
				
			||||||
        </div>
 | 
					                      class="news_event"
 | 
				
			||||||
      {% endfor %}
 | 
					                      x-data="{ newsState: newsDate.news.is_published ? AlertState.PUBLISHED : AlertState.PENDING }"
 | 
				
			||||||
{% else %}
 | 
					                    >
 | 
				
			||||||
  <div class="news_empty">
 | 
					                      <template x-if="!newsDate.news.is_published">
 | 
				
			||||||
    <em>{% trans %}Nothing to come...{% endtrans %}</em>
 | 
					                        {{ news_moderation_alert("newsDate.news.id", user, "newsState") }}
 | 
				
			||||||
  </div>
 | 
					                      </template>
 | 
				
			||||||
{% endif %}
 | 
					                      <div x-show="newsState !== AlertState.DELETED">
 | 
				
			||||||
 | 
					                        <header class="row gap">
 | 
				
			||||||
 | 
					                          <img
 | 
				
			||||||
 | 
					                            :src="newsDate.news.club.logo || '{{ static("com/img/news.png") }}'"
 | 
				
			||||||
 | 
					                            :alt="newsDate.news.club.name"
 | 
				
			||||||
 | 
					                          />
 | 
				
			||||||
 | 
					                          <div class="header_content">
 | 
				
			||||||
 | 
					                            <h4>
 | 
				
			||||||
 | 
					                              <a :href="newsDate.news.url" x-text="newsDate.news.title"></a>
 | 
				
			||||||
 | 
					                            </h4>
 | 
				
			||||||
 | 
					                            <a :href="newsDate.news.club.url" x-text="newsDate.news.club.name"></a>
 | 
				
			||||||
 | 
					                            <div class="news_date">
 | 
				
			||||||
 | 
					                              <time
 | 
				
			||||||
 | 
					                                :datetime="newsDate.start_date.toISOString()"
 | 
				
			||||||
 | 
					                                x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
 | 
				
			||||||
 | 
					                              ></time> -
 | 
				
			||||||
 | 
					                              <time
 | 
				
			||||||
 | 
					                                :datetime="newsDate.end_date.toISOString()"
 | 
				
			||||||
 | 
					                                x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
 | 
				
			||||||
 | 
					                              ></time>
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
 | 
					                          </div>
 | 
				
			||||||
 | 
					                        </header>
 | 
				
			||||||
 | 
					                        {# The API returns a summary in html.
 | 
				
			||||||
 | 
					                           It's generated from our markdown subset, so it should be safe #}
 | 
				
			||||||
 | 
					                        <div class="news_content markdown" x-html="newsDate.news.summary"></div>
 | 
				
			||||||
 | 
					                      </div>
 | 
				
			||||||
 | 
					                    </article>
 | 
				
			||||||
 | 
					                  </template>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div id="load-more-news-button" :aria-busy="loading">
 | 
				
			||||||
 | 
					              <button class="btn btn-grey" x-show="!loading && hasNext" @click="loadMore()">
 | 
				
			||||||
 | 
					                {% trans %}See more{% endtrans %}  <i class="fa fa-arrow-down"></i>
 | 
				
			||||||
 | 
					              </button>
 | 
				
			||||||
 | 
					              <p x-show="!loading && !hasNext">
 | 
				
			||||||
 | 
					                <em>
 | 
				
			||||||
 | 
					                  {% trans trimmed %}
 | 
				
			||||||
 | 
					                    It was too short.
 | 
				
			||||||
 | 
					                    You already reached the end of the upcoming events list.
 | 
				
			||||||
 | 
					                  {% endtrans %}
 | 
				
			||||||
 | 
					                </em>
 | 
				
			||||||
 | 
					              </p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					      </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<h3>{% trans %}All coming events{% endtrans %}</h3>
 | 
					      <h3>
 | 
				
			||||||
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
 | 
					        {% trans %}All coming events{% endtrans %}
 | 
				
			||||||
 | 
					        <a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
 | 
				
			||||||
 | 
					      </h3>
 | 
				
			||||||
</div>
 | 
					      <ics-calendar
 | 
				
			||||||
 | 
					        x-data
 | 
				
			||||||
<div id="right_column">
 | 
					        x-ref="calendar"
 | 
				
			||||||
  <div id="links">
 | 
					        @news-moderated.window="
 | 
				
			||||||
    <h3>{% trans %}Links{% endtrans %}</h3>
 | 
					                                if ($event.target !== $refs.calendar){
 | 
				
			||||||
    <div id="links_content">
 | 
					                                // Avoid triggering a refresh with a dispatch
 | 
				
			||||||
      <h4>{% trans %}Our services{% endtrans %}</h4>
 | 
					                                // from the calendar itself
 | 
				
			||||||
      <ul>
 | 
					                                $refs.calendar.refreshEvents($event);
 | 
				
			||||||
        <li>
 | 
					                                }
 | 
				
			||||||
          <i class="fa-solid fa-graduation-cap fa-xl"></i>
 | 
					                               "
 | 
				
			||||||
          <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
 | 
					        @calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
 | 
				
			||||||
        </li>
 | 
					        @calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
 | 
				
			||||||
        <li>
 | 
					        @calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
 | 
				
			||||||
          <i class="fa-solid fa-magnifying-glass fa-xl"></i>
 | 
					        locale="{{ get_language() }}"
 | 
				
			||||||
          <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
 | 
					        can_moderate="{{ user.has_perm("com.moderate_news") }}"
 | 
				
			||||||
        </li>
 | 
					        can_delete="{{ user.has_perm("com.delete_news") }}"
 | 
				
			||||||
        <li>
 | 
					      ></ics-calendar>
 | 
				
			||||||
          <i class="fa-solid fa-check-to-slot fa-xl"></i>
 | 
					 | 
				
			||||||
          <a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
      </ul>
 | 
					 | 
				
			||||||
      <br>
 | 
					 | 
				
			||||||
      <h4>{% trans %}Social media{% endtrans %}</h4>
 | 
					 | 
				
			||||||
      <ul>
 | 
					 | 
				
			||||||
        <li>
 | 
					 | 
				
			||||||
          <i class="fa-brands fa-discord fa-xl"></i>
 | 
					 | 
				
			||||||
          <a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
 | 
					 | 
				
			||||||
          {% if user.was_subscribed %}
 | 
					 | 
				
			||||||
            - <a rel="nofollow" target="#" href="https://discord.gg/XK9WfPsUFm">{% trans %}Dev Team{% endtrans %}</a>
 | 
					 | 
				
			||||||
          {% endif %}
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
        <li>
 | 
					 | 
				
			||||||
          <i class="fa-brands fa-facebook fa-xl"></i>
 | 
					 | 
				
			||||||
          <a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
        <li>
 | 
					 | 
				
			||||||
          <i class="fa-brands fa-square-instagram fa-xl"></i>
 | 
					 | 
				
			||||||
          <a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
      </ul>
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div id="birthdays">
 | 
					    <div id="right_column">
 | 
				
			||||||
    <h3>{% trans %}Birthdays{% endtrans %}</h3>
 | 
					      <div id="links">
 | 
				
			||||||
    <div id="birthdays_content">
 | 
					        <h3>{% trans %}Links{% endtrans %}</h3>
 | 
				
			||||||
      {%- if user.was_subscribed -%}
 | 
					        <div id="links_content">
 | 
				
			||||||
        <ul class="birthdays_year">
 | 
					          <h4>{% trans %}Our services{% endtrans %}</h4>
 | 
				
			||||||
          {%- for year, users in birthdays -%}
 | 
					          <ul>
 | 
				
			||||||
            <li>
 | 
					            <li>
 | 
				
			||||||
              {% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
 | 
					              <i class="fa-solid fa-graduation-cap fa-xl"></i>
 | 
				
			||||||
              <ul>
 | 
					              <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
 | 
				
			||||||
                {%- for u in users -%}
 | 
					 | 
				
			||||||
                  <li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
 | 
					 | 
				
			||||||
                {%- endfor -%}
 | 
					 | 
				
			||||||
              </ul>
 | 
					 | 
				
			||||||
            </li>
 | 
					            </li>
 | 
				
			||||||
          {%- endfor -%}
 | 
					            <li>
 | 
				
			||||||
        </ul>
 | 
					              <i class="fa-solid fa-magnifying-glass fa-xl"></i>
 | 
				
			||||||
      {%- else -%}
 | 
					              <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
 | 
				
			||||||
        <p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
 | 
					            </li>
 | 
				
			||||||
      {%- endif -%}
 | 
					            <li>
 | 
				
			||||||
 | 
					              <i class="fa-solid fa-check-to-slot fa-xl"></i>
 | 
				
			||||||
 | 
					              <a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					          <br>
 | 
				
			||||||
 | 
					          <h4>{% trans %}Social media{% endtrans %}</h4>
 | 
				
			||||||
 | 
					          <ul>
 | 
				
			||||||
 | 
					            <li>
 | 
				
			||||||
 | 
					              <i class="fa-brands fa-discord fa-xl"></i>
 | 
				
			||||||
 | 
					              <a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">
 | 
				
			||||||
 | 
					                {% trans %}Discord AE{% endtrans %}
 | 
				
			||||||
 | 
					              </a>
 | 
				
			||||||
 | 
					              {% if user.was_subscribed %}
 | 
				
			||||||
 | 
					                - <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">
 | 
				
			||||||
 | 
					                  {% trans %}Dev Team{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					            <li>
 | 
				
			||||||
 | 
					              <i class="fa-brands fa-facebook fa-xl"></i>
 | 
				
			||||||
 | 
					              <a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">
 | 
				
			||||||
 | 
					                Facebook
 | 
				
			||||||
 | 
					              </a>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					            <li>
 | 
				
			||||||
 | 
					              <i class="fa-brands fa-square-instagram fa-xl"></i>
 | 
				
			||||||
 | 
					              <a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">
 | 
				
			||||||
 | 
					                Instagram
 | 
				
			||||||
 | 
					              </a>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div id="birthdays">
 | 
				
			||||||
 | 
					        <h3>{% trans %}Birthdays{% endtrans %}</h3>
 | 
				
			||||||
 | 
					        <div id="birthdays_content">
 | 
				
			||||||
 | 
					          {%- if user.has_perm("core.view_user") -%}
 | 
				
			||||||
 | 
					            <ul class="birthdays_year">
 | 
				
			||||||
 | 
					              {%- for year, users in birthdays -%}
 | 
				
			||||||
 | 
					                <li>
 | 
				
			||||||
 | 
					                  {% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
 | 
				
			||||||
 | 
					                  <ul>
 | 
				
			||||||
 | 
					                    {%- for u in users -%}
 | 
				
			||||||
 | 
					                      <li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
 | 
				
			||||||
 | 
					                    {%- endfor -%}
 | 
				
			||||||
 | 
					                  </ul>
 | 
				
			||||||
 | 
					                </li>
 | 
				
			||||||
 | 
					              {%- endfor -%}
 | 
				
			||||||
 | 
					            </ul>
 | 
				
			||||||
 | 
					          {%- elif not user.was_subscribed -%}
 | 
				
			||||||
 | 
					            {# The user cannot view birthdays, because he never subscribed #}
 | 
				
			||||||
 | 
					            <p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
 | 
				
			||||||
 | 
					          {%- else -%}
 | 
				
			||||||
 | 
					            {# There is another reason why user cannot view birthdays (maybe he is banned)
 | 
				
			||||||
 | 
					               but we cannot know exactly what is this reason #}
 | 
				
			||||||
 | 
					            <p>{% trans %}You cannot access this content{% endtrans %}</p>
 | 
				
			||||||
 | 
					          {%- endif -%}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,24 +3,32 @@ from datetime import datetime, timedelta
 | 
				
			|||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from typing import Callable
 | 
					from typing import Callable
 | 
				
			||||||
from unittest.mock import MagicMock, patch
 | 
					from unittest.mock import MagicMock, patch
 | 
				
			||||||
 | 
					from urllib.parse import quote
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
from django.http import HttpResponse
 | 
					from django.http import HttpResponse
 | 
				
			||||||
from django.test.client import Client
 | 
					from django.test import Client, TestCase
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					from django.utils.timezone import now
 | 
				
			||||||
 | 
					from model_bakery import baker, seq
 | 
				
			||||||
 | 
					from pytest_django.asserts import assertNumQueries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from com.calendar import IcsCalendar
 | 
					from com.calendar import IcsCalendar
 | 
				
			||||||
 | 
					from com.models import News, NewsDate
 | 
				
			||||||
 | 
					from core.markdown import markdown
 | 
				
			||||||
 | 
					from core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@dataclass
 | 
					@dataclass
 | 
				
			||||||
class MockResponse:
 | 
					class MockResponse:
 | 
				
			||||||
    status: int
 | 
					    ok: bool
 | 
				
			||||||
    value: str
 | 
					    value: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def data(self):
 | 
					    def content(self):
 | 
				
			||||||
        return self.value.encode("utf8")
 | 
					        return self.value.encode("utf8")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -38,7 +46,7 @@ class TestExternalCalendar:
 | 
				
			|||||||
    @pytest.fixture
 | 
					    @pytest.fixture
 | 
				
			||||||
    def mock_request(self):
 | 
					    def mock_request(self):
 | 
				
			||||||
        mock = MagicMock()
 | 
					        mock = MagicMock()
 | 
				
			||||||
        with patch("urllib3.request", mock):
 | 
					        with patch("requests.get", mock):
 | 
				
			||||||
            yield mock
 | 
					            yield mock
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @pytest.fixture
 | 
					    @pytest.fixture
 | 
				
			||||||
@@ -52,15 +60,12 @@ class TestExternalCalendar:
 | 
				
			|||||||
    def clear_cache(self):
 | 
					    def clear_cache(self):
 | 
				
			||||||
        IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True)
 | 
					        IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @pytest.mark.parametrize("error_code", [403, 404, 500])
 | 
					    def test_fetch_error(self, client: Client, mock_request: MagicMock):
 | 
				
			||||||
    def test_fetch_error(
 | 
					        mock_request.return_value = MockResponse(ok=False, value="not allowed")
 | 
				
			||||||
        self, client: Client, mock_request: MagicMock, error_code: int
 | 
					 | 
				
			||||||
    ):
 | 
					 | 
				
			||||||
        mock_request.return_value = MockResponse(error_code, "not allowed")
 | 
					 | 
				
			||||||
        assert client.get(reverse("api:calendar_external")).status_code == 404
 | 
					        assert client.get(reverse("api:calendar_external")).status_code == 404
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_fetch_success(self, client: Client, mock_request: MagicMock):
 | 
					    def test_fetch_success(self, client: Client, mock_request: MagicMock):
 | 
				
			||||||
        external_response = MockResponse(200, "Definitely an ICS")
 | 
					        external_response = MockResponse(ok=True, value="Definitely an ICS")
 | 
				
			||||||
        mock_request.return_value = external_response
 | 
					        mock_request.return_value = external_response
 | 
				
			||||||
        response = client.get(reverse("api:calendar_external"))
 | 
					        response = client.get(reverse("api:calendar_external"))
 | 
				
			||||||
        assert response.status_code == 200
 | 
					        assert response.status_code == 200
 | 
				
			||||||
@@ -120,3 +125,126 @@ class TestInternalCalendar:
 | 
				
			|||||||
        out_file = accel_redirect_to_file(response)
 | 
					        out_file = accel_redirect_to_file(response)
 | 
				
			||||||
        assert out_file is not None
 | 
					        assert out_file is not None
 | 
				
			||||||
        assert out_file.exists()
 | 
					        assert out_file.exists()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.django_db
 | 
				
			||||||
 | 
					class TestModerateNews:
 | 
				
			||||||
 | 
					    @pytest.mark.parametrize("news_is_published", [True, False])
 | 
				
			||||||
 | 
					    def test_moderation_ok(self, client: Client, news_is_published: bool):  # noqa FBT
 | 
				
			||||||
 | 
					        user = baker.make(
 | 
				
			||||||
 | 
					            User, user_permissions=[Permission.objects.get(codename="moderate_news")]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # The API call should work even if the news is initially moderated.
 | 
				
			||||||
 | 
					        # In the latter case, the result should be a noop, rather than an error.
 | 
				
			||||||
 | 
					        news = baker.make(News, is_published=news_is_published)
 | 
				
			||||||
 | 
					        initial_moderator = news.moderator
 | 
				
			||||||
 | 
					        client.force_login(user)
 | 
				
			||||||
 | 
					        response = client.patch(
 | 
				
			||||||
 | 
					            reverse("api:moderate_news", kwargs={"news_id": news.id})
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # if it wasn't moderated, it should now be moderated and the moderator should
 | 
				
			||||||
 | 
					        # be the user that made the request.
 | 
				
			||||||
 | 
					        # If it was already moderated, it should be a no-op, but not an error
 | 
				
			||||||
 | 
					        assert response.status_code == 200
 | 
				
			||||||
 | 
					        news.refresh_from_db()
 | 
				
			||||||
 | 
					        assert news.is_published
 | 
				
			||||||
 | 
					        if not news_is_published:
 | 
				
			||||||
 | 
					            assert news.moderator == user
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            assert news.moderator == initial_moderator
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_moderation_forbidden(self, client: Client):
 | 
				
			||||||
 | 
					        user = baker.make(User)
 | 
				
			||||||
 | 
					        news = baker.make(News, is_published=False)
 | 
				
			||||||
 | 
					        client.force_login(user)
 | 
				
			||||||
 | 
					        response = client.patch(
 | 
				
			||||||
 | 
					            reverse("api:moderate_news", kwargs={"news_id": news.id})
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert response.status_code == 403
 | 
				
			||||||
 | 
					        news.refresh_from_db()
 | 
				
			||||||
 | 
					        assert not news.is_published
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.django_db
 | 
				
			||||||
 | 
					class TestDeleteNews:
 | 
				
			||||||
 | 
					    def test_delete_news_ok(self, client: Client):
 | 
				
			||||||
 | 
					        user = baker.make(
 | 
				
			||||||
 | 
					            User, user_permissions=[Permission.objects.get(codename="delete_news")]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        news = baker.make(News)
 | 
				
			||||||
 | 
					        client.force_login(user)
 | 
				
			||||||
 | 
					        response = client.delete(
 | 
				
			||||||
 | 
					            reverse("api:delete_news", kwargs={"news_id": news.id})
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert response.status_code == 200
 | 
				
			||||||
 | 
					        assert not News.objects.filter(id=news.id).exists()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_delete_news_forbidden(self, client: Client):
 | 
				
			||||||
 | 
					        user = baker.make(User)
 | 
				
			||||||
 | 
					        news = baker.make(News)
 | 
				
			||||||
 | 
					        client.force_login(user)
 | 
				
			||||||
 | 
					        response = client.delete(
 | 
				
			||||||
 | 
					            reverse("api:delete_news", kwargs={"news_id": news.id})
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert response.status_code == 403
 | 
				
			||||||
 | 
					        assert News.objects.filter(id=news.id).exists()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestFetchNewsDates(TestCase):
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
 | 
					        News.objects.all().delete()
 | 
				
			||||||
 | 
					        cls.dates = baker.make(
 | 
				
			||||||
 | 
					            NewsDate,
 | 
				
			||||||
 | 
					            _quantity=5,
 | 
				
			||||||
 | 
					            _bulk_create=True,
 | 
				
			||||||
 | 
					            start_date=seq(value=now(), increment_by=timedelta(days=1)),
 | 
				
			||||||
 | 
					            end_date=seq(
 | 
				
			||||||
 | 
					                value=now() + timedelta(hours=2), increment_by=timedelta(days=1)
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            news=iter(
 | 
				
			||||||
 | 
					                baker.make(News, is_published=True, _quantity=5, _bulk_create=True)
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        cls.dates.append(
 | 
				
			||||||
 | 
					            baker.make(
 | 
				
			||||||
 | 
					                NewsDate,
 | 
				
			||||||
 | 
					                start_date=now() + timedelta(days=2, hours=1),
 | 
				
			||||||
 | 
					                end_date=now() + timedelta(days=2, hours=5),
 | 
				
			||||||
 | 
					                news=baker.make(News, is_published=True),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        cls.dates.sort(key=lambda d: d.start_date)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_num_queries(self):
 | 
				
			||||||
 | 
					        with assertNumQueries(2):
 | 
				
			||||||
 | 
					            self.client.get(reverse("api:fetch_news_dates"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_html_format(self):
 | 
				
			||||||
 | 
					        """Test that when the summary is asked in html, the summary is in html."""
 | 
				
			||||||
 | 
					        summary_1 = "# First event\nThere is something happening.\n"
 | 
				
			||||||
 | 
					        self.dates[0].news.summary = summary_1
 | 
				
			||||||
 | 
					        self.dates[0].news.save()
 | 
				
			||||||
 | 
					        summary_2 = (
 | 
				
			||||||
 | 
					            "# Second event\n"
 | 
				
			||||||
 | 
					            "There is something happening **for real**.\n"
 | 
				
			||||||
 | 
					            "Everything is [here](https://youtu.be/dQw4w9WgXcQ)\n"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.dates[1].news.summary = summary_2
 | 
				
			||||||
 | 
					        self.dates[1].news.save()
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("api:fetch_news_dates") + "?page_size=2&text_format=html"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert response.status_code == 200
 | 
				
			||||||
 | 
					        dates = response.json()["results"]
 | 
				
			||||||
 | 
					        assert dates[0]["news"]["summary"] == markdown(summary_1)
 | 
				
			||||||
 | 
					        assert dates[1]["news"]["summary"] == markdown(summary_2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_fetch(self):
 | 
				
			||||||
 | 
					        after = quote((now() + timedelta(days=1)).isoformat())
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("api:fetch_news_dates") + f"?page_size=3&after={after}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert response.status_code == 200
 | 
				
			||||||
 | 
					        dates = response.json()["results"]
 | 
				
			||||||
 | 
					        assert [d["id"] for d in dates] == [d.id for d in self.dates[1:4]]
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										42
									
								
								com/tests/test_models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								com/tests/test_models.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					import itertools
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
 | 
					from django.test import TestCase
 | 
				
			||||||
 | 
					from model_bakery import baker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from com.models import News
 | 
				
			||||||
 | 
					from core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestNewsViewableBy(TestCase):
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
 | 
					        News.objects.all().delete()
 | 
				
			||||||
 | 
					        cls.users = baker.make(User, _quantity=3, _bulk_create=True)
 | 
				
			||||||
 | 
					        # There are six news and six authors.
 | 
				
			||||||
 | 
					        # Each author has one moderated and one non-moderated news
 | 
				
			||||||
 | 
					        cls.news = baker.make(
 | 
				
			||||||
 | 
					            News,
 | 
				
			||||||
 | 
					            author=itertools.cycle(cls.users),
 | 
				
			||||||
 | 
					            is_published=iter([True, True, True, False, False, False]),
 | 
				
			||||||
 | 
					            _quantity=6,
 | 
				
			||||||
 | 
					            _bulk_create=True,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_admin_can_view_everything(self):
 | 
				
			||||||
 | 
					        """Test with a user that can view non moderated news."""
 | 
				
			||||||
 | 
					        user = baker.make(
 | 
				
			||||||
 | 
					            User,
 | 
				
			||||||
 | 
					            user_permissions=[Permission.objects.get(codename="view_unmoderated_news")],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert set(News.objects.viewable_by(user)) == set(self.news)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_normal_user_can_view_moderated_and_self_news(self):
 | 
				
			||||||
 | 
					        """Test that basic users can view moderated news and news they authored."""
 | 
				
			||||||
 | 
					        user = self.news[0].author
 | 
				
			||||||
 | 
					        assert set(News.objects.viewable_by(user)) == {
 | 
				
			||||||
 | 
					            self.news[0],
 | 
				
			||||||
 | 
					            self.news[1],
 | 
				
			||||||
 | 
					            self.news[2],
 | 
				
			||||||
 | 
					            self.news[3],
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
@@ -12,17 +12,24 @@
 | 
				
			|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
					# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
 | 
					from unittest.mock import patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.contrib.sites.models import Site
 | 
				
			||||||
from django.core.files.uploadedfile import SimpleUploadedFile
 | 
					from django.core.files.uploadedfile import SimpleUploadedFile
 | 
				
			||||||
from django.test import TestCase
 | 
					from django.test import TestCase
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils import html
 | 
					from django.utils import html
 | 
				
			||||||
from django.utils.timezone import localtime, now
 | 
					from django.utils.timezone import localtime, now
 | 
				
			||||||
from django.utils.translation import gettext as _
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
 | 
					from model_bakery import baker
 | 
				
			||||||
 | 
					from pytest_django.asserts import assertNumQueries, assertRedirects
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from club.models import Club, Membership
 | 
					from club.models import Club, Membership
 | 
				
			||||||
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
 | 
					from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
 | 
				
			||||||
 | 
					from core.baker_recipes import subscriber_user
 | 
				
			||||||
from core.models import AnonymousUser, Group, User
 | 
					from core.models import AnonymousUser, Group, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -137,15 +144,8 @@ class TestNews(TestCase):
 | 
				
			|||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def setUpTestData(cls):
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
        cls.com_admin = User.objects.get(username="comunity")
 | 
					        cls.com_admin = User.objects.get(username="comunity")
 | 
				
			||||||
        new = News.objects.create(
 | 
					        cls.new = baker.make(News)
 | 
				
			||||||
            title="dummy new",
 | 
					        cls.author = cls.new.author
 | 
				
			||||||
            summary="This is a dummy new",
 | 
					 | 
				
			||||||
            content="Look at that beautiful dummy new",
 | 
					 | 
				
			||||||
            author=User.objects.get(username="subscriber"),
 | 
					 | 
				
			||||||
            club=Club.objects.first(),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        cls.new = new
 | 
					 | 
				
			||||||
        cls.author = new.author
 | 
					 | 
				
			||||||
        cls.sli = User.objects.get(username="sli")
 | 
					        cls.sli = User.objects.get(username="sli")
 | 
				
			||||||
        cls.anonymous = AnonymousUser()
 | 
					        cls.anonymous = AnonymousUser()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -160,15 +160,15 @@ class TestNews(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_news_viewer(self):
 | 
					    def test_news_viewer(self):
 | 
				
			||||||
        """Test that moderated news can be viewed by anyone
 | 
					        """Test that moderated news can be viewed by anyone
 | 
				
			||||||
        and not moderated news only by com admins.
 | 
					        and not moderated news only by com admins and by their author.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        # by default a news isn't moderated
 | 
					        # by default news aren't moderated
 | 
				
			||||||
        assert self.new.can_be_viewed_by(self.com_admin)
 | 
					        assert self.new.can_be_viewed_by(self.com_admin)
 | 
				
			||||||
 | 
					        assert self.new.can_be_viewed_by(self.author)
 | 
				
			||||||
        assert not self.new.can_be_viewed_by(self.sli)
 | 
					        assert not self.new.can_be_viewed_by(self.sli)
 | 
				
			||||||
        assert not self.new.can_be_viewed_by(self.anonymous)
 | 
					        assert not self.new.can_be_viewed_by(self.anonymous)
 | 
				
			||||||
        assert not self.new.can_be_viewed_by(self.author)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.new.is_moderated = True
 | 
					        self.new.is_published = True
 | 
				
			||||||
        self.new.save()
 | 
					        self.new.save()
 | 
				
			||||||
        assert self.new.can_be_viewed_by(self.com_admin)
 | 
					        assert self.new.can_be_viewed_by(self.com_admin)
 | 
				
			||||||
        assert self.new.can_be_viewed_by(self.sli)
 | 
					        assert self.new.can_be_viewed_by(self.sli)
 | 
				
			||||||
@@ -176,11 +176,11 @@ class TestNews(TestCase):
 | 
				
			|||||||
        assert self.new.can_be_viewed_by(self.author)
 | 
					        assert self.new.can_be_viewed_by(self.author)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_news_editor(self):
 | 
					    def test_news_editor(self):
 | 
				
			||||||
        """Test that only com admins can edit news."""
 | 
					        """Test that only com admins and the original author can edit news."""
 | 
				
			||||||
        assert self.new.can_be_edited_by(self.com_admin)
 | 
					        assert self.new.can_be_edited_by(self.com_admin)
 | 
				
			||||||
 | 
					        assert self.new.can_be_edited_by(self.author)
 | 
				
			||||||
        assert not self.new.can_be_edited_by(self.sli)
 | 
					        assert not self.new.can_be_edited_by(self.sli)
 | 
				
			||||||
        assert not self.new.can_be_edited_by(self.anonymous)
 | 
					        assert not self.new.can_be_edited_by(self.anonymous)
 | 
				
			||||||
        assert not self.new.can_be_edited_by(self.author)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestWeekmailArticle(TestCase):
 | 
					class TestWeekmailArticle(TestCase):
 | 
				
			||||||
@@ -230,3 +230,105 @@ class TestPoster(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        assert not self.poster.is_owned_by(self.susbcriber)
 | 
					        assert not self.poster.is_owned_by(self.susbcriber)
 | 
				
			||||||
        assert self.poster.is_owned_by(self.sli)
 | 
					        assert self.poster.is_owned_by(self.sli)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestNewsCreation(TestCase):
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
 | 
					        cls.club = baker.make(Club)
 | 
				
			||||||
 | 
					        cls.user = subscriber_user.make()
 | 
				
			||||||
 | 
					        baker.make(Membership, user=cls.user, club=cls.club, role=5)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        self.start = now() + timedelta(days=1)
 | 
				
			||||||
 | 
					        self.end = self.start + timedelta(hours=5)
 | 
				
			||||||
 | 
					        self.valid_payload = {
 | 
				
			||||||
 | 
					            "title": "Test news",
 | 
				
			||||||
 | 
					            "summary": "This is a test news",
 | 
				
			||||||
 | 
					            "content": "This is a test news",
 | 
				
			||||||
 | 
					            "club": self.club.pk,
 | 
				
			||||||
 | 
					            "is_weekly": False,
 | 
				
			||||||
 | 
					            "start_date": self.start,
 | 
				
			||||||
 | 
					            "end_date": self.end,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_create_news(self):
 | 
				
			||||||
 | 
					        response = self.client.post(reverse("com:news_new"), self.valid_payload)
 | 
				
			||||||
 | 
					        created = News.objects.order_by("id").last()
 | 
				
			||||||
 | 
					        assertRedirects(response, created.get_absolute_url())
 | 
				
			||||||
 | 
					        assert created.title == "Test news"
 | 
				
			||||||
 | 
					        assert not created.is_published
 | 
				
			||||||
 | 
					        dates = list(created.dates.values("start_date", "end_date"))
 | 
				
			||||||
 | 
					        assert dates == [{"start_date": self.start, "end_date": self.end}]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_create_news_multiple_dates(self):
 | 
				
			||||||
 | 
					        self.valid_payload["is_weekly"] = True
 | 
				
			||||||
 | 
					        self.valid_payload["occurrences"] = 2
 | 
				
			||||||
 | 
					        response = self.client.post(reverse("com:news_new"), self.valid_payload)
 | 
				
			||||||
 | 
					        created = News.objects.order_by("id").last()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assertRedirects(response, created.get_absolute_url())
 | 
				
			||||||
 | 
					        dates = list(
 | 
				
			||||||
 | 
					            created.dates.values("start_date", "end_date").order_by("start_date")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert dates == [
 | 
				
			||||||
 | 
					            {"start_date": self.start, "end_date": self.end},
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "start_date": self.start + timedelta(days=7),
 | 
				
			||||||
 | 
					                "end_date": self.end + timedelta(days=7),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_edit_news(self):
 | 
				
			||||||
 | 
					        news = baker.make(News, author=self.user, is_published=True)
 | 
				
			||||||
 | 
					        baker.make(
 | 
				
			||||||
 | 
					            NewsDate,
 | 
				
			||||||
 | 
					            news=news,
 | 
				
			||||||
 | 
					            start_date=self.start + timedelta(hours=1),
 | 
				
			||||||
 | 
					            end_date=self.end + timedelta(hours=1),
 | 
				
			||||||
 | 
					            _quantity=2,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            reverse("com:news_edit", kwargs={"news_id": news.id}), self.valid_payload
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        created = News.objects.order_by("id").last()
 | 
				
			||||||
 | 
					        assertRedirects(response, created.get_absolute_url())
 | 
				
			||||||
 | 
					        assert created.title == "Test news"
 | 
				
			||||||
 | 
					        assert not created.is_published
 | 
				
			||||||
 | 
					        dates = list(created.dates.values("start_date", "end_date"))
 | 
				
			||||||
 | 
					        assert dates == [{"start_date": self.start, "end_date": self.end}]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_ics_updated(self):
 | 
				
			||||||
 | 
					        """Test that the internal ICS is updated when news are created"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # we will just test that the ICS is modified.
 | 
				
			||||||
 | 
					        # Checking that the ICS is *well* modified is up to the ICS tests
 | 
				
			||||||
 | 
					        with patch("com.calendar.IcsCalendar.make_internal") as mocked:
 | 
				
			||||||
 | 
					            self.client.post(reverse("com:news_new"), self.valid_payload)
 | 
				
			||||||
 | 
					            mocked.assert_called()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # The ICS file should also change after an update
 | 
				
			||||||
 | 
					        self.valid_payload["is_weekly"] = True
 | 
				
			||||||
 | 
					        self.valid_payload["occurrences"] = 2
 | 
				
			||||||
 | 
					        last_news = News.objects.order_by("id").last()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with patch("com.calendar.IcsCalendar.make_internal") as mocked:
 | 
				
			||||||
 | 
					            self.client.post(
 | 
				
			||||||
 | 
					                reverse("com:news_edit", kwargs={"news_id": last_news.id}),
 | 
				
			||||||
 | 
					                self.valid_payload,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            mocked.assert_called()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.django_db
 | 
				
			||||||
 | 
					def test_feed(client):
 | 
				
			||||||
 | 
					    """Smoke test that checks that the atom feed is working"""
 | 
				
			||||||
 | 
					    Site.objects.clear_cache()
 | 
				
			||||||
 | 
					    with assertNumQueries(2):
 | 
				
			||||||
 | 
					        # get sith domain with Site api: 1 request
 | 
				
			||||||
 | 
					        # get all news and related info: 1 request
 | 
				
			||||||
 | 
					        resp = client.get(reverse("com:news_feed"))
 | 
				
			||||||
 | 
					        assert resp.status_code == 200
 | 
				
			||||||
 | 
					        assert resp.headers["Content-Type"] == "application/rss+xml; charset=utf-8"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,9 +25,10 @@ from com.views import (
 | 
				
			|||||||
    NewsCreateView,
 | 
					    NewsCreateView,
 | 
				
			||||||
    NewsDeleteView,
 | 
					    NewsDeleteView,
 | 
				
			||||||
    NewsDetailView,
 | 
					    NewsDetailView,
 | 
				
			||||||
    NewsEditView,
 | 
					    NewsFeed,
 | 
				
			||||||
    NewsListView,
 | 
					    NewsListView,
 | 
				
			||||||
    NewsModerateView,
 | 
					    NewsModerateView,
 | 
				
			||||||
 | 
					    NewsUpdateView,
 | 
				
			||||||
    PosterCreateView,
 | 
					    PosterCreateView,
 | 
				
			||||||
    PosterDeleteView,
 | 
					    PosterDeleteView,
 | 
				
			||||||
    PosterEditView,
 | 
					    PosterEditView,
 | 
				
			||||||
@@ -73,13 +74,14 @@ urlpatterns = [
 | 
				
			|||||||
        name="weekmail_article_edit",
 | 
					        name="weekmail_article_edit",
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    path("news/", NewsListView.as_view(), name="news_list"),
 | 
					    path("news/", NewsListView.as_view(), name="news_list"),
 | 
				
			||||||
 | 
					    path("news/feed/", NewsFeed(), name="news_feed"),
 | 
				
			||||||
    path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
 | 
					    path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
 | 
				
			||||||
    path("news/create/", NewsCreateView.as_view(), name="news_new"),
 | 
					    path("news/create/", NewsCreateView.as_view(), name="news_new"),
 | 
				
			||||||
 | 
					    path("news/<int:news_id>/edit/", NewsUpdateView.as_view(), name="news_edit"),
 | 
				
			||||||
    path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
 | 
					    path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
        "news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
 | 
					        "news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    path("news/<int:news_id>/edit/", NewsEditView.as_view(), name="news_edit"),
 | 
					 | 
				
			||||||
    path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
 | 
					    path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
 | 
				
			||||||
    path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
 | 
					    path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										395
									
								
								com/views.py
									
									
									
									
									
								
							
							
						
						
									
										395
									
								
								com/views.py
									
									
									
									
									
								
							@@ -22,36 +22,37 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
import itertools
 | 
					import itertools
 | 
				
			||||||
from datetime import timedelta
 | 
					from datetime import date, timedelta
 | 
				
			||||||
from smtplib import SMTPRecipientsRefused
 | 
					from smtplib import SMTPRecipientsRefused
 | 
				
			||||||
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django import forms
 | 
					from dateutil.relativedelta import relativedelta
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
 | 
				
			||||||
 | 
					from django.contrib.syndication.views import Feed
 | 
				
			||||||
from django.core.exceptions import PermissionDenied, ValidationError
 | 
					from django.core.exceptions import PermissionDenied, ValidationError
 | 
				
			||||||
from django.db.models import Exists, Max, OuterRef
 | 
					from django.db.models import Max
 | 
				
			||||||
from django.forms.models import modelform_factory
 | 
					from django.forms.models import modelform_factory
 | 
				
			||||||
from django.http import HttpResponseRedirect
 | 
					from django.http import HttpResponseRedirect
 | 
				
			||||||
from django.shortcuts import get_object_or_404, redirect
 | 
					from django.shortcuts import get_object_or_404, redirect
 | 
				
			||||||
from django.urls import reverse, reverse_lazy
 | 
					from django.urls import reverse, reverse_lazy
 | 
				
			||||||
from django.utils import timezone
 | 
					from django.utils import timezone
 | 
				
			||||||
from django.utils.timezone import localdate
 | 
					from django.utils.timezone import localdate, now
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from django.views.generic import DetailView, ListView, View
 | 
					from django.views.generic import DetailView, ListView, TemplateView, View
 | 
				
			||||||
from django.views.generic.detail import SingleObjectMixin
 | 
					 | 
				
			||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
					from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from club.models import Club, Mailing
 | 
					from club.models import Club, Mailing
 | 
				
			||||||
 | 
					from com.calendar import IcsCalendar
 | 
				
			||||||
 | 
					from com.forms import NewsDateForm, NewsForm, PosterForm
 | 
				
			||||||
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
 | 
					from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
 | 
				
			||||||
from core.models import Notification, User
 | 
					from core.auth.mixins import (
 | 
				
			||||||
from core.views import (
 | 
					 | 
				
			||||||
    CanCreateMixin,
 | 
					 | 
				
			||||||
    CanEditMixin,
 | 
					 | 
				
			||||||
    CanEditPropMixin,
 | 
					    CanEditPropMixin,
 | 
				
			||||||
    CanViewMixin,
 | 
					    CanViewMixin,
 | 
				
			||||||
    QuickNotifMixin,
 | 
					    PermissionOrAuthorRequiredMixin,
 | 
				
			||||||
    TabedViewMixin,
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from core.views.forms import SelectDateTime
 | 
					from core.models import User
 | 
				
			||||||
 | 
					from core.views.mixins import QuickNotifMixin, TabedViewMixin
 | 
				
			||||||
from core.views.widgets.markdown import MarkdownInput
 | 
					from core.views.widgets.markdown import MarkdownInput
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Sith object
 | 
					# Sith object
 | 
				
			||||||
@@ -59,92 +60,47 @@ from core.views.widgets.markdown import MarkdownInput
 | 
				
			|||||||
sith = Sith.objects.first
 | 
					sith = Sith.objects.first
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PosterForm(forms.ModelForm):
 | 
					 | 
				
			||||||
    class Meta:
 | 
					 | 
				
			||||||
        model = Poster
 | 
					 | 
				
			||||||
        fields = [
 | 
					 | 
				
			||||||
            "name",
 | 
					 | 
				
			||||||
            "file",
 | 
					 | 
				
			||||||
            "club",
 | 
					 | 
				
			||||||
            "screens",
 | 
					 | 
				
			||||||
            "date_begin",
 | 
					 | 
				
			||||||
            "date_end",
 | 
					 | 
				
			||||||
            "display_time",
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
        widgets = {"screens": forms.CheckboxSelectMultiple}
 | 
					 | 
				
			||||||
        help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    date_begin = forms.DateTimeField(
 | 
					 | 
				
			||||||
        label=_("Start date"),
 | 
					 | 
				
			||||||
        widget=SelectDateTime,
 | 
					 | 
				
			||||||
        required=True,
 | 
					 | 
				
			||||||
        initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    date_end = forms.DateTimeField(
 | 
					 | 
				
			||||||
        label=_("End date"), widget=SelectDateTime, required=False
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        self.user = kwargs.pop("user", None)
 | 
					 | 
				
			||||||
        super().__init__(*args, **kwargs)
 | 
					 | 
				
			||||||
        if self.user and not self.user.is_com_admin:
 | 
					 | 
				
			||||||
            self.fields["club"].queryset = Club.objects.filter(
 | 
					 | 
				
			||||||
                id__in=self.user.clubs_with_rights
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            self.fields.pop("display_time")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ComTabsMixin(TabedViewMixin):
 | 
					class ComTabsMixin(TabedViewMixin):
 | 
				
			||||||
    def get_tabs_title(self):
 | 
					    def get_tabs_title(self):
 | 
				
			||||||
        return _("Communication administration")
 | 
					        return _("Communication administration")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_list_of_tabs(self):
 | 
					    def get_list_of_tabs(self):
 | 
				
			||||||
        tab_list = []
 | 
					        return [
 | 
				
			||||||
        tab_list.append(
 | 
					            {"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")},
 | 
				
			||||||
            {"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")}
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        tab_list.append(
 | 
					 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse("com:weekmail_destinations"),
 | 
					                "url": reverse("com:weekmail_destinations"),
 | 
				
			||||||
                "slug": "weekmail_destinations",
 | 
					                "slug": "weekmail_destinations",
 | 
				
			||||||
                "name": _("Weekmail destinations"),
 | 
					                "name": _("Weekmail destinations"),
 | 
				
			||||||
            }
 | 
					            },
 | 
				
			||||||
        )
 | 
					            {
 | 
				
			||||||
        tab_list.append(
 | 
					                "url": reverse("com:info_edit"),
 | 
				
			||||||
            {"url": reverse("com:info_edit"), "slug": "info", "name": _("Info message")}
 | 
					                "slug": "info",
 | 
				
			||||||
        )
 | 
					                "name": _("Info message"),
 | 
				
			||||||
        tab_list.append(
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse("com:alert_edit"),
 | 
					                "url": reverse("com:alert_edit"),
 | 
				
			||||||
                "slug": "alert",
 | 
					                "slug": "alert",
 | 
				
			||||||
                "name": _("Alert message"),
 | 
					                "name": _("Alert message"),
 | 
				
			||||||
            }
 | 
					            },
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        tab_list.append(
 | 
					 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse("com:mailing_admin"),
 | 
					                "url": reverse("com:mailing_admin"),
 | 
				
			||||||
                "slug": "mailings",
 | 
					                "slug": "mailings",
 | 
				
			||||||
                "name": _("Mailing lists administration"),
 | 
					                "name": _("Mailing lists administration"),
 | 
				
			||||||
            }
 | 
					            },
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        tab_list.append(
 | 
					 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse("com:poster_list"),
 | 
					                "url": reverse("com:poster_list"),
 | 
				
			||||||
                "slug": "posters",
 | 
					                "slug": "posters",
 | 
				
			||||||
                "name": _("Posters list"),
 | 
					                "name": _("Posters list"),
 | 
				
			||||||
            }
 | 
					            },
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        tab_list.append(
 | 
					 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse("com:screen_list"),
 | 
					                "url": reverse("com:screen_list"),
 | 
				
			||||||
                "slug": "screens",
 | 
					                "slug": "screens",
 | 
				
			||||||
                "name": _("Screens list"),
 | 
					                "name": _("Screens list"),
 | 
				
			||||||
            }
 | 
					            },
 | 
				
			||||||
        )
 | 
					        ]
 | 
				
			||||||
        return tab_list
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class IsComAdminMixin(View):
 | 
					class IsComAdminMixin(AccessMixin):
 | 
				
			||||||
    def dispatch(self, request, *args, **kwargs):
 | 
					    def dispatch(self, request, *args, **kwargs):
 | 
				
			||||||
        if not request.user.is_com_admin:
 | 
					        if not request.user.is_com_admin:
 | 
				
			||||||
            raise PermissionDenied
 | 
					            raise PermissionDenied
 | 
				
			||||||
@@ -184,174 +140,86 @@ class WeekmailDestinationEditView(ComEditView):
 | 
				
			|||||||
# News
 | 
					# News
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NewsForm(forms.ModelForm):
 | 
					class NewsCreateView(PermissionRequiredMixin, CreateView):
 | 
				
			||||||
    class Meta:
 | 
					    """View to either create or update News."""
 | 
				
			||||||
        model = News
 | 
					
 | 
				
			||||||
        fields = ["type", "title", "club", "summary", "content", "author"]
 | 
					    model = News
 | 
				
			||||||
        widgets = {
 | 
					    form_class = NewsForm
 | 
				
			||||||
            "author": forms.HiddenInput,
 | 
					    template_name = "com/news_edit.jinja"
 | 
				
			||||||
            "type": forms.RadioSelect,
 | 
					    permission_required = "com.add_news"
 | 
				
			||||||
            "summary": MarkdownInput,
 | 
					
 | 
				
			||||||
            "content": MarkdownInput,
 | 
					    def get_date_form_kwargs(self) -> dict[str, Any]:
 | 
				
			||||||
 | 
					        """Get initial data for NewsDateForm"""
 | 
				
			||||||
 | 
					        if self.request.method == "POST":
 | 
				
			||||||
 | 
					            return {"data": self.request.POST}
 | 
				
			||||||
 | 
					        return {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_form_kwargs(self):
 | 
				
			||||||
 | 
					        return super().get_form_kwargs() | {
 | 
				
			||||||
 | 
					            "author": self.request.user,
 | 
				
			||||||
 | 
					            "date_form": NewsDateForm(**self.get_date_form_kwargs()),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    start_date = forms.DateTimeField(
 | 
					    def get_initial(self):
 | 
				
			||||||
        label=_("Start date"), widget=SelectDateTime, required=False
 | 
					        init = super().get_initial()
 | 
				
			||||||
    )
 | 
					        # if the id of a club is provided, select it by default
 | 
				
			||||||
    end_date = forms.DateTimeField(
 | 
					        if club_id := self.request.GET.get("club"):
 | 
				
			||||||
        label=_("End date"), widget=SelectDateTime, required=False
 | 
					            init["club"] = Club.objects.filter(id=club_id).first()
 | 
				
			||||||
    )
 | 
					        return init
 | 
				
			||||||
    until = forms.DateTimeField(label=_("Until"), widget=SelectDateTime, required=False)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    automoderation = forms.BooleanField(label=_("Automoderation"), required=False)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def clean(self):
 | 
					 | 
				
			||||||
        self.cleaned_data = super().clean()
 | 
					 | 
				
			||||||
        if self.cleaned_data["type"] != "NOTICE":
 | 
					 | 
				
			||||||
            if not self.cleaned_data["start_date"]:
 | 
					 | 
				
			||||||
                self.add_error(
 | 
					 | 
				
			||||||
                    "start_date", ValidationError(_("This field is required."))
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            if not self.cleaned_data["end_date"]:
 | 
					 | 
				
			||||||
                self.add_error(
 | 
					 | 
				
			||||||
                    "end_date", ValidationError(_("This field is required."))
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            if (
 | 
					 | 
				
			||||||
                not self.has_error("start_date")
 | 
					 | 
				
			||||||
                and not self.has_error("end_date")
 | 
					 | 
				
			||||||
                and self.cleaned_data["start_date"] > self.cleaned_data["end_date"]
 | 
					 | 
				
			||||||
            ):
 | 
					 | 
				
			||||||
                self.add_error(
 | 
					 | 
				
			||||||
                    "end_date",
 | 
					 | 
				
			||||||
                    ValidationError(_("An event cannot end before its beginning.")),
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
 | 
					 | 
				
			||||||
                self.add_error("until", ValidationError(_("This field is required.")))
 | 
					 | 
				
			||||||
        return self.cleaned_data
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					 | 
				
			||||||
        ret = super().save()
 | 
					 | 
				
			||||||
        self.instance.dates.all().delete()
 | 
					 | 
				
			||||||
        if self.instance.type == "EVENT" or self.instance.type == "CALL":
 | 
					 | 
				
			||||||
            NewsDate(
 | 
					 | 
				
			||||||
                start_date=self.cleaned_data["start_date"],
 | 
					 | 
				
			||||||
                end_date=self.cleaned_data["end_date"],
 | 
					 | 
				
			||||||
                news=self.instance,
 | 
					 | 
				
			||||||
            ).save()
 | 
					 | 
				
			||||||
        elif self.instance.type == "WEEKLY":
 | 
					 | 
				
			||||||
            start_date = self.cleaned_data["start_date"]
 | 
					 | 
				
			||||||
            end_date = self.cleaned_data["end_date"]
 | 
					 | 
				
			||||||
            while start_date <= self.cleaned_data["until"]:
 | 
					 | 
				
			||||||
                NewsDate(
 | 
					 | 
				
			||||||
                    start_date=start_date, end_date=end_date, news=self.instance
 | 
					 | 
				
			||||||
                ).save()
 | 
					 | 
				
			||||||
                start_date += timedelta(days=7)
 | 
					 | 
				
			||||||
                end_date += timedelta(days=7)
 | 
					 | 
				
			||||||
        return ret
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NewsEditView(CanEditMixin, UpdateView):
 | 
					class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
 | 
				
			||||||
    model = News
 | 
					    model = News
 | 
				
			||||||
    form_class = NewsForm
 | 
					    form_class = NewsForm
 | 
				
			||||||
    template_name = "com/news_edit.jinja"
 | 
					    template_name = "com/news_edit.jinja"
 | 
				
			||||||
    pk_url_kwarg = "news_id"
 | 
					    pk_url_kwarg = "news_id"
 | 
				
			||||||
 | 
					    permission_required = "com.edit_news"
 | 
				
			||||||
    def get_initial(self):
 | 
					 | 
				
			||||||
        news_date: NewsDate = self.object.dates.order_by("id").first()
 | 
					 | 
				
			||||||
        if news_date is None:
 | 
					 | 
				
			||||||
            return {"start_date": None, "end_date": None}
 | 
					 | 
				
			||||||
        return {"start_date": news_date.start_date, "end_date": news_date.end_date}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def post(self, request, *args, **kwargs):
 | 
					 | 
				
			||||||
        form = self.get_form()
 | 
					 | 
				
			||||||
        if form.is_valid() and "preview" not in request.POST:
 | 
					 | 
				
			||||||
            return self.form_valid(form)
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            return self.form_invalid(form)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def form_valid(self, form):
 | 
					    def form_valid(self, form):
 | 
				
			||||||
        self.object = form.save()
 | 
					        response = super().form_valid(form)  # Does the saving part
 | 
				
			||||||
        if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
 | 
					        IcsCalendar.make_internal()
 | 
				
			||||||
            self.object.moderator = self.request.user
 | 
					        return response
 | 
				
			||||||
            self.object.is_moderated = True
 | 
					
 | 
				
			||||||
            self.object.save()
 | 
					    def get_date_form_kwargs(self) -> dict[str, Any]:
 | 
				
			||||||
        else:
 | 
					        """Get initial data for NewsDateForm"""
 | 
				
			||||||
            self.object.is_moderated = False
 | 
					        response = {}
 | 
				
			||||||
            self.object.save()
 | 
					        if self.request.method == "POST":
 | 
				
			||||||
            unread_notif_subquery = Notification.objects.filter(
 | 
					            response["data"] = self.request.POST
 | 
				
			||||||
                user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
 | 
					        dates = list(self.object.dates.order_by("id"))
 | 
				
			||||||
            )
 | 
					        if len(dates) == 0:
 | 
				
			||||||
            for user in User.objects.filter(
 | 
					            return {}
 | 
				
			||||||
                ~Exists(unread_notif_subquery),
 | 
					        response["instance"] = dates[0]
 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
					        occurrences = NewsDateForm.get_occurrences(len(dates))
 | 
				
			||||||
            ):
 | 
					        if occurrences is not None:
 | 
				
			||||||
                Notification.objects.create(
 | 
					            response["initial"] = {"is_weekly": True, "occurrences": occurrences}
 | 
				
			||||||
                    user=user,
 | 
					        return response
 | 
				
			||||||
                    url=self.object.get_absolute_url(),
 | 
					
 | 
				
			||||||
                    type="NEWS_MODERATION",
 | 
					    def get_form_kwargs(self):
 | 
				
			||||||
                )
 | 
					        return super().get_form_kwargs() | {
 | 
				
			||||||
        return super().form_valid(form)
 | 
					            "author": self.request.user,
 | 
				
			||||||
 | 
					            "date_form": NewsDateForm(**self.get_date_form_kwargs()),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NewsCreateView(CanCreateMixin, CreateView):
 | 
					class NewsDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
 | 
				
			||||||
    model = News
 | 
					 | 
				
			||||||
    form_class = NewsForm
 | 
					 | 
				
			||||||
    template_name = "com/news_edit.jinja"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_initial(self):
 | 
					 | 
				
			||||||
        init = {"author": self.request.user}
 | 
					 | 
				
			||||||
        if "club" not in self.request.GET:
 | 
					 | 
				
			||||||
            return init
 | 
					 | 
				
			||||||
        init["club"] = Club.objects.filter(id=self.request.GET["club"]).first()
 | 
					 | 
				
			||||||
        return init
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def post(self, request, *args, **kwargs):
 | 
					 | 
				
			||||||
        form = self.get_form()
 | 
					 | 
				
			||||||
        if form.is_valid() and "preview" not in request.POST:
 | 
					 | 
				
			||||||
            return self.form_valid(form)
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            self.object = form.instance
 | 
					 | 
				
			||||||
            return self.form_invalid(form)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def form_valid(self, form):
 | 
					 | 
				
			||||||
        self.object = form.save()
 | 
					 | 
				
			||||||
        if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
 | 
					 | 
				
			||||||
            self.object.moderator = self.request.user
 | 
					 | 
				
			||||||
            self.object.is_moderated = True
 | 
					 | 
				
			||||||
            self.object.save()
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            unread_notif_subquery = Notification.objects.filter(
 | 
					 | 
				
			||||||
                user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            for user in User.objects.filter(
 | 
					 | 
				
			||||||
                ~Exists(unread_notif_subquery),
 | 
					 | 
				
			||||||
                groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
 | 
					 | 
				
			||||||
            ):
 | 
					 | 
				
			||||||
                Notification.objects.create(
 | 
					 | 
				
			||||||
                    user=user,
 | 
					 | 
				
			||||||
                    url=reverse("com:news_admin_list"),
 | 
					 | 
				
			||||||
                    type="NEWS_MODERATION",
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
        return super().form_valid(form)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class NewsDeleteView(CanEditMixin, DeleteView):
 | 
					 | 
				
			||||||
    model = News
 | 
					    model = News
 | 
				
			||||||
    pk_url_kwarg = "news_id"
 | 
					    pk_url_kwarg = "news_id"
 | 
				
			||||||
    template_name = "core/delete_confirm.jinja"
 | 
					    template_name = "core/delete_confirm.jinja"
 | 
				
			||||||
    success_url = reverse_lazy("com:news_admin_list")
 | 
					    success_url = reverse_lazy("com:news_admin_list")
 | 
				
			||||||
 | 
					    permission_required = "com.delete_news"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NewsModerateView(CanEditMixin, SingleObjectMixin):
 | 
					class NewsModerateView(PermissionRequiredMixin, DetailView):
 | 
				
			||||||
    model = News
 | 
					    model = News
 | 
				
			||||||
    pk_url_kwarg = "news_id"
 | 
					    pk_url_kwarg = "news_id"
 | 
				
			||||||
 | 
					    permission_required = "com.moderate_news"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get(self, request, *args, **kwargs):
 | 
					    def get(self, request, *args, **kwargs):
 | 
				
			||||||
        self.object = self.get_object()
 | 
					        self.object = self.get_object()
 | 
				
			||||||
        if "remove" in request.GET:
 | 
					        if "remove" in request.GET:
 | 
				
			||||||
            self.object.is_moderated = False
 | 
					            self.object.is_published = False
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            self.object.is_moderated = True
 | 
					            self.object.is_published = True
 | 
				
			||||||
        self.object.moderator = request.user
 | 
					        self.object.moderator = request.user
 | 
				
			||||||
        self.object.save()
 | 
					        self.object.save()
 | 
				
			||||||
        if "next" in self.request.GET:
 | 
					        if "next" in self.request.GET:
 | 
				
			||||||
@@ -359,37 +227,112 @@ class NewsModerateView(CanEditMixin, SingleObjectMixin):
 | 
				
			|||||||
        return redirect("com:news_admin_list")
 | 
					        return redirect("com:news_admin_list")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NewsAdminListView(CanEditMixin, ListView):
 | 
					class NewsAdminListView(PermissionRequiredMixin, ListView):
 | 
				
			||||||
    model = News
 | 
					    model = News
 | 
				
			||||||
    template_name = "com/news_admin_list.jinja"
 | 
					    template_name = "com/news_admin_list.jinja"
 | 
				
			||||||
    queryset = News.objects.all()
 | 
					    queryset = News.objects.select_related(
 | 
				
			||||||
 | 
					        "club", "author", "moderator"
 | 
				
			||||||
 | 
					    ).prefetch_related("dates")
 | 
				
			||||||
 | 
					    permission_required = ["com.moderate_news", "com.delete_news"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NewsListView(CanViewMixin, ListView):
 | 
					class NewsListView(TemplateView):
 | 
				
			||||||
    model = News
 | 
					 | 
				
			||||||
    template_name = "com/news_list.jinja"
 | 
					    template_name = "com/news_list.jinja"
 | 
				
			||||||
    queryset = News.objects.filter(is_moderated=True)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_context_data(self, **kwargs):
 | 
					    def get_birthdays(self):
 | 
				
			||||||
        kwargs = super().get_context_data(**kwargs)
 | 
					        if not self.request.user.has_perm("core.view_user"):
 | 
				
			||||||
        kwargs["NewsDate"] = NewsDate
 | 
					            return []
 | 
				
			||||||
        kwargs["timedelta"] = timedelta
 | 
					        return itertools.groupby(
 | 
				
			||||||
        kwargs["birthdays"] = itertools.groupby(
 | 
					 | 
				
			||||||
            User.objects.filter(
 | 
					            User.objects.filter(
 | 
				
			||||||
                date_of_birth__month=localdate().month,
 | 
					                date_of_birth__month=localdate().month,
 | 
				
			||||||
                date_of_birth__day=localdate().day,
 | 
					                date_of_birth__day=localdate().day,
 | 
				
			||||||
 | 
					                is_subscriber_viewable=True,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            .filter(role__in=["STUDENT", "FORMER STUDENT"])
 | 
					            .filter(role__in=["STUDENT", "FORMER STUDENT"])
 | 
				
			||||||
            .order_by("-date_of_birth"),
 | 
					            .order_by("-date_of_birth"),
 | 
				
			||||||
            key=lambda u: u.date_of_birth.year,
 | 
					            key=lambda u: u.date_of_birth.year,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return kwargs
 | 
					
 | 
				
			||||||
 | 
					    def get_last_day(self) -> date | None:
 | 
				
			||||||
 | 
					        """Get the last day when news will be displayed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        The returned day is the third one where something happen.
 | 
				
			||||||
 | 
					        For example, if there are 6 events : A on 15/03, B and C on 17/03,
 | 
				
			||||||
 | 
					        D on 20/03, E on 21/03 and F on 22/03 ;
 | 
				
			||||||
 | 
					        then the result is 20/03.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        dates = list(
 | 
				
			||||||
 | 
					            NewsDate.objects.filter(end_date__gt=now())
 | 
				
			||||||
 | 
					            .order_by("start_date")
 | 
				
			||||||
 | 
					            .values_list("start_date__date", flat=True)
 | 
				
			||||||
 | 
					            .distinct()[:4]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return dates[-1] if len(dates) > 0 else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_news_dates(self, until: date) -> dict[date, list[date]]:
 | 
				
			||||||
 | 
					        """Return the event dates to display.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        The selected events are the ones that happens between
 | 
				
			||||||
 | 
					        right now and the given day (included).
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					            date: list(dates)
 | 
				
			||||||
 | 
					            for date, dates in itertools.groupby(
 | 
				
			||||||
 | 
					                NewsDate.objects.viewable_by(self.request.user)
 | 
				
			||||||
 | 
					                .filter(end_date__gt=now(), start_date__date__lte=until)
 | 
				
			||||||
 | 
					                .order_by("start_date")
 | 
				
			||||||
 | 
					                .select_related("news", "news__club"),
 | 
				
			||||||
 | 
					                key=lambda d: d.start_date.date(),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
 | 
					        last_day = self.get_last_day()
 | 
				
			||||||
 | 
					        return super().get_context_data(**kwargs) | {
 | 
				
			||||||
 | 
					            "news_dates": self.get_news_dates(until=last_day)
 | 
				
			||||||
 | 
					            if last_day is not None
 | 
				
			||||||
 | 
					            else {},
 | 
				
			||||||
 | 
					            "birthdays": self.get_birthdays(),
 | 
				
			||||||
 | 
					            "last_day": last_day,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NewsDetailView(CanViewMixin, DetailView):
 | 
					class NewsDetailView(CanViewMixin, DetailView):
 | 
				
			||||||
    model = News
 | 
					    model = News
 | 
				
			||||||
    template_name = "com/news_detail.jinja"
 | 
					    template_name = "com/news_detail.jinja"
 | 
				
			||||||
    pk_url_kwarg = "news_id"
 | 
					    pk_url_kwarg = "news_id"
 | 
				
			||||||
 | 
					    queryset = News.objects.select_related("club", "author", "moderator")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
 | 
					        return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsFeed(Feed):
 | 
				
			||||||
 | 
					    title = _("News")
 | 
				
			||||||
 | 
					    link = reverse_lazy("com:news_list")
 | 
				
			||||||
 | 
					    description = _("All incoming events")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def items(self):
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            NewsDate.objects.filter(
 | 
				
			||||||
 | 
					                news__is_published=True,
 | 
				
			||||||
 | 
					                end_date__gte=timezone.now() - (relativedelta(months=6)),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .select_related("news", "news__author")
 | 
				
			||||||
 | 
					            .order_by("-start_date")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def item_title(self, item: NewsDate):
 | 
				
			||||||
 | 
					        return item.news.title
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def item_description(self, item: NewsDate):
 | 
				
			||||||
 | 
					        return item.news.summary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def item_link(self, item: NewsDate):
 | 
				
			||||||
 | 
					        return item.news.get_absolute_url()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def item_author_name(self, item: NewsDate):
 | 
				
			||||||
 | 
					        return item.news.author.get_display_name()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Weekmail
 | 
					# Weekmail
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,10 +11,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
 | 
				
			|||||||
from ninja_extra.schemas import PaginatedResponseSchema
 | 
					from ninja_extra.schemas import PaginatedResponseSchema
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from club.models import Mailing
 | 
					from club.models import Mailing
 | 
				
			||||||
from core.api_permissions import (
 | 
					from core.auth.api_permissions import CanAccessLookup, CanView
 | 
				
			||||||
    CanAccessLookup,
 | 
					 | 
				
			||||||
    CanView,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
from core.models import Group, SithFile, User
 | 
					from core.models import Group, SithFile, User
 | 
				
			||||||
from core.schemas import (
 | 
					from core.schemas import (
 | 
				
			||||||
    FamilyGodfatherSchema,
 | 
					    FamilyGodfatherSchema,
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										0
									
								
								core/auth/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								core/auth/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -3,7 +3,8 @@
 | 
				
			|||||||
Some permissions are global (like `IsInGroup` or `IsRoot`),
 | 
					Some permissions are global (like `IsInGroup` or `IsRoot`),
 | 
				
			||||||
and some others are per-object (like `CanView` or `CanEdit`).
 | 
					and some others are per-object (like `CanView` or `CanEdit`).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Examples:
 | 
					Example:
 | 
				
			||||||
 | 
					    ```python
 | 
				
			||||||
    # restrict all the routes of this controller
 | 
					    # restrict all the routes of this controller
 | 
				
			||||||
    # to subscribed users
 | 
					    # to subscribed users
 | 
				
			||||||
    @api_controller("/foo", permissions=[IsSubscriber])
 | 
					    @api_controller("/foo", permissions=[IsSubscriber])
 | 
				
			||||||
@@ -33,10 +34,14 @@ Examples:
 | 
				
			|||||||
        ]
 | 
					        ]
 | 
				
			||||||
        def bar_delete(self, bar_id: int):
 | 
					        def bar_delete(self, bar_id: int):
 | 
				
			||||||
            # ...
 | 
					            # ...
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import operator
 | 
				
			||||||
 | 
					from functools import reduce
 | 
				
			||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from ninja_extra import ControllerBase
 | 
					from ninja_extra import ControllerBase
 | 
				
			||||||
from ninja_extra.permissions import BasePermission
 | 
					from ninja_extra.permissions import BasePermission
 | 
				
			||||||
@@ -54,6 +59,46 @@ class IsInGroup(BasePermission):
 | 
				
			|||||||
        return request.user.is_in_group(pk=self._group_pk)
 | 
					        return request.user.is_in_group(pk=self._group_pk)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class HasPerm(BasePermission):
 | 
				
			||||||
 | 
					    """Check that the user has the required perm.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    If multiple perms are given, a comparer function can also be passed,
 | 
				
			||||||
 | 
					    in order to change the way perms are checked.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Example:
 | 
				
			||||||
 | 
					        ```python
 | 
				
			||||||
 | 
					        # this route will require both permissions
 | 
				
			||||||
 | 
					        @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
 | 
				
			||||||
 | 
					        def foo(self): ...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # This route will require at least one of the perm,
 | 
				
			||||||
 | 
					        # but it's not mandatory to have all of them
 | 
				
			||||||
 | 
					        @route.put(
 | 
				
			||||||
 | 
					            "/bar",
 | 
				
			||||||
 | 
					            permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        def bar(self): ...
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(
 | 
				
			||||||
 | 
					        self, perms: str | Permission | list[str | Permission], op=operator.and_
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Args:
 | 
				
			||||||
 | 
					            perms: a permission or a list of permissions the user must have
 | 
				
			||||||
 | 
					            op: An operator to combine multiple permissions (in most cases,
 | 
				
			||||||
 | 
					                it will be either `operator.and_` or `operator.or_`)
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					        if not isinstance(perms, (list, tuple, set)):
 | 
				
			||||||
 | 
					            perms = [perms]
 | 
				
			||||||
 | 
					        self._operator = op
 | 
				
			||||||
 | 
					        self._perms = perms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
 | 
				
			||||||
 | 
					        return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class IsRoot(BasePermission):
 | 
					class IsRoot(BasePermission):
 | 
				
			||||||
    """Check that the user is root."""
 | 
					    """Check that the user is root."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										287
									
								
								core/auth/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								core/auth/mixins.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,287 @@
 | 
				
			|||||||
 | 
					#
 | 
				
			||||||
 | 
					# Copyright 2016,2017
 | 
				
			||||||
 | 
					# - Skia <skia@libskia.so>
 | 
				
			||||||
 | 
					# - Sli <antoine@bartuccio.fr>
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
 | 
				
			||||||
 | 
					# http://ae.utbm.fr.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This program is free software; you can redistribute it and/or modify it under
 | 
				
			||||||
 | 
					# the terms of the GNU General Public License a published by the Free Software
 | 
				
			||||||
 | 
					# Foundation; either version 3 of the License, or (at your option) any later
 | 
				
			||||||
 | 
					# version.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This program is distributed in the hope that it will be useful, but WITHOUT
 | 
				
			||||||
 | 
					# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 | 
				
			||||||
 | 
					# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 | 
				
			||||||
 | 
					# details.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# You should have received a copy of the GNU General Public License along with
 | 
				
			||||||
 | 
					# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
 | 
				
			||||||
 | 
					# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					from __future__ import annotations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import types
 | 
				
			||||||
 | 
					import warnings
 | 
				
			||||||
 | 
					from typing import TYPE_CHECKING, Any, LiteralString
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
 | 
				
			||||||
 | 
					from django.core.exceptions import ImproperlyConfigured, PermissionDenied
 | 
				
			||||||
 | 
					from django.views.generic.base import View
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if TYPE_CHECKING:
 | 
				
			||||||
 | 
					    from django.db.models import Model
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def can_edit_prop(obj: Any, user: User) -> bool:
 | 
				
			||||||
 | 
					    """Can the user edit the properties of the object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        obj: Object to test for permission
 | 
				
			||||||
 | 
					        user: core.models.User to test permissions against
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        True if user is authorized to edit object properties else False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Example:
 | 
				
			||||||
 | 
					        ```python
 | 
				
			||||||
 | 
					        if not can_edit_prop(self.object ,request.user):
 | 
				
			||||||
 | 
					            raise PermissionDenied
 | 
				
			||||||
 | 
					        ```
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return obj is None or user.is_owner(obj)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def can_edit(obj: Any, user: User) -> bool:
 | 
				
			||||||
 | 
					    """Can the user edit the object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        obj: Object to test for permission
 | 
				
			||||||
 | 
					        user: core.models.User to test permissions against
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        True if user is authorized to edit object else False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Example:
 | 
				
			||||||
 | 
					        ```python
 | 
				
			||||||
 | 
					        if not can_edit(self.object, request.user):
 | 
				
			||||||
 | 
					            raise PermissionDenied
 | 
				
			||||||
 | 
					        ```
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if obj is None or user.can_edit(obj):
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					    return can_edit_prop(obj, user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def can_view(obj: Any, user: User) -> bool:
 | 
				
			||||||
 | 
					    """Can the user see the object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        obj: Object to test for permission
 | 
				
			||||||
 | 
					        user: core.models.User to test permissions against
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        True if user is authorized to see object else False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Example:
 | 
				
			||||||
 | 
					        ```python
 | 
				
			||||||
 | 
					        if not can_view(self.object ,request.user):
 | 
				
			||||||
 | 
					            raise PermissionDenied
 | 
				
			||||||
 | 
					        ```
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if obj is None or user.can_view(obj):
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					    return can_edit(obj, user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GenericContentPermissionMixinBuilder(View):
 | 
				
			||||||
 | 
					    """Used to build permission mixins.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This view protect any child view that would be showing an object that is restricted based
 | 
				
			||||||
 | 
					      on two properties.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Attributes:
 | 
				
			||||||
 | 
					        raised_error: permission to be raised
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    raised_error = PermissionDenied
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def permission_function(obj: Any, user: User) -> bool:
 | 
				
			||||||
 | 
					        """Function to test permission with."""
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def get_permission_function(cls, obj, user):
 | 
				
			||||||
 | 
					        return cls.permission_function(obj, user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dispatch(self, request, *arg, **kwargs):
 | 
				
			||||||
 | 
					        if hasattr(self, "get_object") and callable(self.get_object):
 | 
				
			||||||
 | 
					            self.object = self.get_object()
 | 
				
			||||||
 | 
					            if not self.get_permission_function(self.object, request.user):
 | 
				
			||||||
 | 
					                raise self.raised_error
 | 
				
			||||||
 | 
					            return super().dispatch(request, *arg, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # If we get here, it's a ListView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        queryset = self.get_queryset()
 | 
				
			||||||
 | 
					        l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
 | 
				
			||||||
 | 
					        if not l_id and queryset.count() != 0:
 | 
				
			||||||
 | 
					            raise self.raised_error
 | 
				
			||||||
 | 
					        self._get_queryset = self.get_queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def get_qs(self2):
 | 
				
			||||||
 | 
					            return self2._get_queryset().filter(id__in=l_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.get_queryset = types.MethodType(get_qs, self)
 | 
				
			||||||
 | 
					        return super().dispatch(request, *arg, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CanCreateMixin(View):
 | 
				
			||||||
 | 
					    """Protect any child view that would create an object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Raises:
 | 
				
			||||||
 | 
					        PermissionDenied:
 | 
				
			||||||
 | 
					            If the user has not the necessary permission
 | 
				
			||||||
 | 
					            to create the object of the view.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init_subclass__(cls, **kwargs):
 | 
				
			||||||
 | 
					        warnings.warn(
 | 
				
			||||||
 | 
					            f"{cls.__name__} is deprecated and should be replaced "
 | 
				
			||||||
 | 
					            "by other permission verification mecanism.",
 | 
				
			||||||
 | 
					            DeprecationWarning,
 | 
				
			||||||
 | 
					            stacklevel=2,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        super().__init_subclass__(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        warnings.warn(
 | 
				
			||||||
 | 
					            f"{self.__class__.__name__} is deprecated and should be replaced "
 | 
				
			||||||
 | 
					            "by other permission verification mecanism.",
 | 
				
			||||||
 | 
					            DeprecationWarning,
 | 
				
			||||||
 | 
					            stacklevel=2,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dispatch(self, request, *arg, **kwargs):
 | 
				
			||||||
 | 
					        res = super().dispatch(request, *arg, **kwargs)
 | 
				
			||||||
 | 
					        if not request.user.is_authenticated:
 | 
				
			||||||
 | 
					            raise PermissionDenied
 | 
				
			||||||
 | 
					        return res
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def form_valid(self, form):
 | 
				
			||||||
 | 
					        obj = form.instance
 | 
				
			||||||
 | 
					        if can_edit_prop(obj, self.request.user):
 | 
				
			||||||
 | 
					            return super().form_valid(form)
 | 
				
			||||||
 | 
					        raise PermissionDenied
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CanEditPropMixin(GenericContentPermissionMixinBuilder):
 | 
				
			||||||
 | 
					    """Ensure the user has owner permissions on the child view object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    In other word, you can make a view with this view as parent,
 | 
				
			||||||
 | 
					    and it will be retricted to the users that are in the
 | 
				
			||||||
 | 
					    object's owner_group or that pass the `obj.can_be_viewed_by` test.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Raises:
 | 
				
			||||||
 | 
					        PermissionDenied: If the user cannot see the object
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    permission_function = can_edit_prop
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CanEditMixin(GenericContentPermissionMixinBuilder):
 | 
				
			||||||
 | 
					    """Ensure the user has permission to edit this view's object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Raises:
 | 
				
			||||||
 | 
					        PermissionDenied: if the user cannot edit this view's object.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    permission_function = can_edit
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CanViewMixin(GenericContentPermissionMixinBuilder):
 | 
				
			||||||
 | 
					    """Ensure the user has permission to view this view's object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Raises:
 | 
				
			||||||
 | 
					        PermissionDenied: if the user cannot edit this view's object.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    permission_function = can_view
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FormerSubscriberMixin(AccessMixin):
 | 
				
			||||||
 | 
					    """Check if the user was at least an old subscriber.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Raises:
 | 
				
			||||||
 | 
					        PermissionDenied: if the user never subscribed.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dispatch(self, request, *args, **kwargs):
 | 
				
			||||||
 | 
					        if not request.user.was_subscribed:
 | 
				
			||||||
 | 
					            raise PermissionDenied
 | 
				
			||||||
 | 
					        return super().dispatch(request, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
 | 
				
			||||||
 | 
					    """Require that the user has the required perm or is the object author.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This mixin can be used in combination with `DetailView`,
 | 
				
			||||||
 | 
					    or another base class that implements the `get_object` method.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Example:
 | 
				
			||||||
 | 
					        In the following code, a user will be able
 | 
				
			||||||
 | 
					        to edit news if he has the `com.change_news` permission
 | 
				
			||||||
 | 
					        or if he tries to edit his own news :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ```python
 | 
				
			||||||
 | 
					        class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
 | 
				
			||||||
 | 
					            model = News
 | 
				
			||||||
 | 
					            author_field = "author"
 | 
				
			||||||
 | 
					            permission_required = "com.change_news"
 | 
				
			||||||
 | 
					        ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        This is more or less equivalent to :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ```python
 | 
				
			||||||
 | 
					        class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
 | 
				
			||||||
 | 
					            model = News
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            def dispatch(self, request, *args, **kwargs):
 | 
				
			||||||
 | 
					                self.object = self.get_object()
 | 
				
			||||||
 | 
					                if not (
 | 
				
			||||||
 | 
					                    user.has_perm("com.change_news")
 | 
				
			||||||
 | 
					                    or self.object.author == request.user
 | 
				
			||||||
 | 
					                ):
 | 
				
			||||||
 | 
					                    raise PermissionDenied
 | 
				
			||||||
 | 
					                return super().dispatch(request, *args, **kwargs)
 | 
				
			||||||
 | 
					        ```
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    author_field: LiteralString = "author"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def has_permission(self):
 | 
				
			||||||
 | 
					        if not hasattr(self, "get_object"):
 | 
				
			||||||
 | 
					            raise ImproperlyConfigured(
 | 
				
			||||||
 | 
					                f"{self.__class__.__name__} is missing the "
 | 
				
			||||||
 | 
					                "get_object attribute. "
 | 
				
			||||||
 | 
					                f"Define {self.__class__.__name__}.get_object, "
 | 
				
			||||||
 | 
					                "or inherit from a class that implement it (like DetailView)"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        if super().has_permission():
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        if self.request.user.is_anonymous:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        obj: Model = self.get_object()
 | 
				
			||||||
 | 
					        if not self.author_field.endswith("_id"):
 | 
				
			||||||
 | 
					            # getting the related model could trigger a db query
 | 
				
			||||||
 | 
					            # so we will rather get the foreign value than
 | 
				
			||||||
 | 
					            # the object itself.
 | 
				
			||||||
 | 
					            self.author_field += "_id"
 | 
				
			||||||
 | 
					        author_id = getattr(obj, self.author_field, None)
 | 
				
			||||||
 | 
					        return author_id == self.request.user.id
 | 
				
			||||||
@@ -13,42 +13,12 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import hashlib
 | 
					 | 
				
			||||||
import multiprocessing
 | 
					 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
import platform
 | 
					 | 
				
			||||||
import shutil
 | 
					 | 
				
			||||||
import subprocess
 | 
					import subprocess
 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
import tarfile
 | 
					 | 
				
			||||||
from dataclasses import dataclass
 | 
					 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from typing import Self
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import tomli
 | 
					import tomli
 | 
				
			||||||
import urllib3
 | 
					from django.core.management.base import BaseCommand, CommandParser
 | 
				
			||||||
from django.core.management.base import BaseCommand, CommandParser, OutputWrapper
 | 
					 | 
				
			||||||
from urllib3.response import HTTPException
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@dataclass
 | 
					 | 
				
			||||||
class XapianSpec:
 | 
					 | 
				
			||||||
    version: str
 | 
					 | 
				
			||||||
    core_sha1: str
 | 
					 | 
				
			||||||
    bindings_sha1: str
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @classmethod
 | 
					 | 
				
			||||||
    def from_pyproject(cls) -> Self:
 | 
					 | 
				
			||||||
        with open(
 | 
					 | 
				
			||||||
            Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
 | 
					 | 
				
			||||||
        ) as f:
 | 
					 | 
				
			||||||
            pyproject = tomli.load(f)
 | 
					 | 
				
			||||||
            spec = pyproject["tool"]["xapian"]
 | 
					 | 
				
			||||||
            return cls(
 | 
					 | 
				
			||||||
                version=spec["version"],
 | 
					 | 
				
			||||||
                core_sha1=spec["core-sha1"],
 | 
					 | 
				
			||||||
                bindings_sha1=spec["bindings-sha1"],
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Command(BaseCommand):
 | 
					class Command(BaseCommand):
 | 
				
			||||||
@@ -69,6 +39,13 @@ class Command(BaseCommand):
 | 
				
			|||||||
            return None
 | 
					            return None
 | 
				
			||||||
        return xapian.version_string()
 | 
					        return xapian.version_string()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _desired_version(self) -> str:
 | 
				
			||||||
 | 
					        with open(
 | 
				
			||||||
 | 
					            Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
 | 
				
			||||||
 | 
					        ) as f:
 | 
				
			||||||
 | 
					            pyproject = tomli.load(f)
 | 
				
			||||||
 | 
					            return pyproject["tool"]["xapian"]["version"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def handle(self, *args, force: bool, **options):
 | 
					    def handle(self, *args, force: bool, **options):
 | 
				
			||||||
        if not os.environ.get("VIRTUAL_ENV", None):
 | 
					        if not os.environ.get("VIRTUAL_ENV", None):
 | 
				
			||||||
            self.stdout.write(
 | 
					            self.stdout.write(
 | 
				
			||||||
@@ -76,185 +53,20 @@ class Command(BaseCommand):
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        desired = XapianSpec.from_pyproject()
 | 
					        desired = self._desired_version()
 | 
				
			||||||
        if desired.version == self._current_version():
 | 
					        if desired == self._current_version():
 | 
				
			||||||
            if not force:
 | 
					            if not force:
 | 
				
			||||||
                self.stdout.write(
 | 
					                self.stdout.write(
 | 
				
			||||||
                    f"Version {desired.version} is already installed, use --force to re-install"
 | 
					                    f"Version {desired} is already installed, use --force to re-install"
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
            self.stdout.write(
 | 
					            self.stdout.write(f"Version {desired} is already installed, re-installing")
 | 
				
			||||||
                f"Version {desired.version} is already installed, re-installing"
 | 
					        self.stdout.write(
 | 
				
			||||||
            )
 | 
					            f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
 | 
				
			||||||
        XapianInstaller(desired, self.stdout, self.stderr).run()
 | 
					        )
 | 
				
			||||||
 | 
					        subprocess.run(
 | 
				
			||||||
 | 
					            [str(Path(__file__).parent / "install_xapian.sh"), desired],
 | 
				
			||||||
 | 
					            env=dict(os.environ),
 | 
				
			||||||
 | 
					            check=True,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        self.stdout.write("Installation success")
 | 
					        self.stdout.write("Installation success")
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class XapianInstaller:
 | 
					 | 
				
			||||||
    def __init__(
 | 
					 | 
				
			||||||
        self,
 | 
					 | 
				
			||||||
        spec: XapianSpec,
 | 
					 | 
				
			||||||
        stdout: OutputWrapper,
 | 
					 | 
				
			||||||
        stderr: OutputWrapper,
 | 
					 | 
				
			||||||
    ):
 | 
					 | 
				
			||||||
        self._version = spec.version
 | 
					 | 
				
			||||||
        self._core_sha1 = spec.core_sha1
 | 
					 | 
				
			||||||
        self._bindings_sha1 = spec.bindings_sha1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self._stdout = stdout
 | 
					 | 
				
			||||||
        self._stderr = stderr
 | 
					 | 
				
			||||||
        self._virtual_env = os.environ.get("VIRTUAL_ENV", None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if not self._virtual_env:
 | 
					 | 
				
			||||||
            raise RuntimeError("You are not inside a virtual environment")
 | 
					 | 
				
			||||||
        self._virtual_env = Path(self._virtual_env)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self._dest_dir = Path(self._virtual_env) / "packages"
 | 
					 | 
				
			||||||
        self._core = f"xapian-core-{self._version}"
 | 
					 | 
				
			||||||
        self._bindings = f"xapian-bindings-{self._version}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def _is_windows(self) -> bool:
 | 
					 | 
				
			||||||
        return platform.system() == "Windows"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _util_download(self, url: str, dest: Path, sha1_hash: str) -> None:
 | 
					 | 
				
			||||||
        resp = urllib3.request("GET", url)
 | 
					 | 
				
			||||||
        if resp.status != 200:
 | 
					 | 
				
			||||||
            raise HTTPException(f"Could not download {url}")
 | 
					 | 
				
			||||||
        if hashlib.sha1(resp.data).hexdigest() != sha1_hash:
 | 
					 | 
				
			||||||
            raise ValueError(f"File downloaded from {url} is compromised")
 | 
					 | 
				
			||||||
        with open(dest, "wb") as f:
 | 
					 | 
				
			||||||
            f.write(resp.data)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _setup_env(self):
 | 
					 | 
				
			||||||
        os.environ.update(
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                "CPATH": "",
 | 
					 | 
				
			||||||
                "LIBRARY_PATH": "",
 | 
					 | 
				
			||||||
                "CFLAGS": "",
 | 
					 | 
				
			||||||
                "LDFLAGS": "",
 | 
					 | 
				
			||||||
                "CCFLAGS": "",
 | 
					 | 
				
			||||||
                "CXXFLAGS": "",
 | 
					 | 
				
			||||||
                "CPPFLAGS": "",
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _prepare_dest_folder(self):
 | 
					 | 
				
			||||||
        shutil.rmtree(self._dest_dir, ignore_errors=True)
 | 
					 | 
				
			||||||
        self._dest_dir.mkdir(parents=True, exist_ok=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _download(self):
 | 
					 | 
				
			||||||
        self._stdout.write("Downloading source…")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        core = self._dest_dir / f"{self._core}.tar.xz"
 | 
					 | 
				
			||||||
        bindings = self._dest_dir / f"{self._bindings}.tar.xz"
 | 
					 | 
				
			||||||
        self._util_download(
 | 
					 | 
				
			||||||
            f"https://oligarchy.co.uk/xapian/{self._version}/{self._core}.tar.xz",
 | 
					 | 
				
			||||||
            core,
 | 
					 | 
				
			||||||
            self._core_sha1,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self._util_download(
 | 
					 | 
				
			||||||
            f"https://oligarchy.co.uk/xapian/{self._version}/{self._bindings}.tar.xz",
 | 
					 | 
				
			||||||
            bindings,
 | 
					 | 
				
			||||||
            self._bindings_sha1,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self._stdout.write("Extracting source …")
 | 
					 | 
				
			||||||
        with tarfile.open(core) as tar:
 | 
					 | 
				
			||||||
            tar.extractall(self._dest_dir)
 | 
					 | 
				
			||||||
        with tarfile.open(bindings) as tar:
 | 
					 | 
				
			||||||
            tar.extractall(self._dest_dir)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        os.remove(core)
 | 
					 | 
				
			||||||
        os.remove(bindings)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _install(self):
 | 
					 | 
				
			||||||
        self._stdout.write("Installing Xapian-core…")
 | 
					 | 
				
			||||||
        def configure() -> list[str]:
 | 
					 | 
				
			||||||
            if self._is_windows:
 | 
					 | 
				
			||||||
                return ["sh", "configure"]
 | 
					 | 
				
			||||||
            return ["./configure"]
 | 
					 | 
				
			||||||
        def enable_static() -> list[str]:
 | 
					 | 
				
			||||||
            if self._is_windows:
 | 
					 | 
				
			||||||
                return ["--enable-shared", "--disable-static"]
 | 
					 | 
				
			||||||
            return []
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Make sure that xapian finds the correct executable
 | 
					 | 
				
			||||||
        os.environ["PYTHON3"] = str(Path(sys.executable).as_posix())
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        subprocess.run(
 | 
					 | 
				
			||||||
            [*configure(), "--prefix", str(self._virtual_env.as_posix()), *enable_static(),],
 | 
					 | 
				
			||||||
            env=dict(os.environ),
 | 
					 | 
				
			||||||
            cwd=self._dest_dir / self._core,
 | 
					 | 
				
			||||||
            check=False,
 | 
					 | 
				
			||||||
            shell=self._is_windows,
 | 
					 | 
				
			||||||
        ).check_returncode()
 | 
					 | 
				
			||||||
        subprocess.run(
 | 
					 | 
				
			||||||
            [
 | 
					 | 
				
			||||||
                "make",
 | 
					 | 
				
			||||||
                "-j",
 | 
					 | 
				
			||||||
                str(multiprocessing.cpu_count()),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            env=dict(os.environ),
 | 
					 | 
				
			||||||
            cwd=self._dest_dir / self._core,
 | 
					 | 
				
			||||||
            check=False,
 | 
					 | 
				
			||||||
            shell=self._is_windows,
 | 
					 | 
				
			||||||
        ).check_returncode()
 | 
					 | 
				
			||||||
        subprocess.run(
 | 
					 | 
				
			||||||
            ["make", "install"],
 | 
					 | 
				
			||||||
            env=dict(os.environ),
 | 
					 | 
				
			||||||
            cwd=self._dest_dir / self._core,
 | 
					 | 
				
			||||||
            check=False,
 | 
					 | 
				
			||||||
            shell=self._is_windows,
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        ).check_returncode()
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self._stdout.write("Installing Xapian-bindings")
 | 
					 | 
				
			||||||
        subprocess.run(
 | 
					 | 
				
			||||||
            [
 | 
					 | 
				
			||||||
                *configure(),
 | 
					 | 
				
			||||||
                "--prefix",
 | 
					 | 
				
			||||||
                str(self._virtual_env.as_posix()),
 | 
					 | 
				
			||||||
                "--with-python3",
 | 
					 | 
				
			||||||
                f"XAPIAN_CONFIG={(self._virtual_env / 'bin'/'xapian-config').as_posix()}",
 | 
					 | 
				
			||||||
                *enable_static(),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            env=dict(os.environ),
 | 
					 | 
				
			||||||
            cwd=self._dest_dir / self._bindings,
 | 
					 | 
				
			||||||
            check=False,
 | 
					 | 
				
			||||||
            shell=self._is_windows,
 | 
					 | 
				
			||||||
        ).check_returncode()
 | 
					 | 
				
			||||||
        subprocess.run(
 | 
					 | 
				
			||||||
            [
 | 
					 | 
				
			||||||
                "make",
 | 
					 | 
				
			||||||
                "-j",
 | 
					 | 
				
			||||||
                str(multiprocessing.cpu_count()),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            env=dict(os.environ),
 | 
					 | 
				
			||||||
            cwd=self._dest_dir / self._bindings,
 | 
					 | 
				
			||||||
            check=False,
 | 
					 | 
				
			||||||
            shell=self._is_windows,
 | 
					 | 
				
			||||||
        ).check_returncode()
 | 
					 | 
				
			||||||
        subprocess.run(
 | 
					 | 
				
			||||||
            ["make", "install"],
 | 
					 | 
				
			||||||
            env=dict(os.environ),
 | 
					 | 
				
			||||||
            cwd=self._dest_dir / self._bindings,
 | 
					 | 
				
			||||||
            check=False,
 | 
					 | 
				
			||||||
            shell=self._is_windows,
 | 
					 | 
				
			||||||
        ).check_returncode()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _post_clean(self):
 | 
					 | 
				
			||||||
        shutil.rmtree(self._dest_dir, ignore_errors=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _test(self):
 | 
					 | 
				
			||||||
        subprocess.run(
 | 
					 | 
				
			||||||
            [sys.executable, "-c", "import xapian"], check=False, shell=self._is_windows,
 | 
					 | 
				
			||||||
        ).check_returncode()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def run(self):
 | 
					 | 
				
			||||||
        self._setup_env()
 | 
					 | 
				
			||||||
        self._prepare_dest_folder()
 | 
					 | 
				
			||||||
        self._download()
 | 
					 | 
				
			||||||
        self._install()
 | 
					 | 
				
			||||||
        self._post_clean()
 | 
					 | 
				
			||||||
        self._test()
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										47
									
								
								core/management/commands/install_xapian.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										47
									
								
								core/management/commands/install_xapian.sh
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					#!/usr/bin/env bash
 | 
				
			||||||
 | 
					# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
 | 
				
			||||||
 | 
					# first argument of the script is Xapian version (e.g. 1.2.19)
 | 
				
			||||||
 | 
					VERSION="$1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Cleanup env vars for auto discovery mechanism
 | 
				
			||||||
 | 
					export CPATH=
 | 
				
			||||||
 | 
					export LIBRARY_PATH=
 | 
				
			||||||
 | 
					export CFLAGS=
 | 
				
			||||||
 | 
					export LDFLAGS=
 | 
				
			||||||
 | 
					export CCFLAGS=
 | 
				
			||||||
 | 
					export CXXFLAGS=
 | 
				
			||||||
 | 
					export CPPFLAGS=
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# prepare
 | 
				
			||||||
 | 
					rm -rf "$VIRTUAL_ENV/packages"
 | 
				
			||||||
 | 
					mkdir -p "$VIRTUAL_ENV/packages" && cd "$VIRTUAL_ENV/packages" || exit 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CORE=xapian-core-$VERSION
 | 
				
			||||||
 | 
					BINDINGS=xapian-bindings-$VERSION
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# download
 | 
				
			||||||
 | 
					echo "Downloading source..."
 | 
				
			||||||
 | 
					curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
 | 
				
			||||||
 | 
					curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# extract
 | 
				
			||||||
 | 
					echo "Extracting source..."
 | 
				
			||||||
 | 
					tar xf "${CORE}.tar.xz"
 | 
				
			||||||
 | 
					tar xf "${BINDINGS}.tar.xz"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# install
 | 
				
			||||||
 | 
					echo "Installing Xapian-core..."
 | 
				
			||||||
 | 
					cd "$VIRTUAL_ENV/packages/${CORE}" || exit 1
 | 
				
			||||||
 | 
					./configure --prefix="$VIRTUAL_ENV" && make -j"$(nproc)" && make install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					PYTHON_FLAG=--with-python3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					echo "Installing Xapian-bindings..."
 | 
				
			||||||
 | 
					cd "$VIRTUAL_ENV/packages/${BINDINGS}" || exit 1
 | 
				
			||||||
 | 
					./configure --prefix="$VIRTUAL_ENV" $PYTHON_FLAG XAPIAN_CONFIG="$VIRTUAL_ENV/bin/xapian-config" && make -j"$(nproc)" && make install
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# clean
 | 
				
			||||||
 | 
					rm -rf "$VIRTUAL_ENV/packages"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# test
 | 
				
			||||||
 | 
					python -c "import xapian"
 | 
				
			||||||
@@ -92,7 +92,12 @@ class Command(BaseCommand):
 | 
				
			|||||||
            raise Exception("Never call this command in prod. Never.")
 | 
					            raise Exception("Never call this command in prod. Never.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
 | 
					        Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
 | 
				
			||||||
        Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
 | 
					
 | 
				
			||||||
 | 
					        site = Site.objects.get_current()
 | 
				
			||||||
 | 
					        site.domain = settings.SITH_URL
 | 
				
			||||||
 | 
					        site.name = settings.SITH_NAME
 | 
				
			||||||
 | 
					        site.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        groups = self._create_groups()
 | 
					        groups = self._create_groups()
 | 
				
			||||||
        self._create_ban_groups()
 | 
					        self._create_ban_groups()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -120,6 +125,11 @@ class Command(BaseCommand):
 | 
				
			|||||||
            unix_name=settings.SITH_MAIN_CLUB["unix_name"],
 | 
					            unix_name=settings.SITH_MAIN_CLUB["unix_name"],
 | 
				
			||||||
            address=settings.SITH_MAIN_CLUB["address"],
 | 
					            address=settings.SITH_MAIN_CLUB["address"],
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        main_club.board_group.permissions.add(
 | 
				
			||||||
 | 
					            *Permission.objects.filter(
 | 
				
			||||||
 | 
					                codename__in=["view_subscription", "add_subscription"]
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        bar_club = Club.objects.create(
 | 
					        bar_club = Club.objects.create(
 | 
				
			||||||
            id=2,
 | 
					            id=2,
 | 
				
			||||||
            name=settings.SITH_BAR_MANAGER["name"],
 | 
					            name=settings.SITH_BAR_MANAGER["name"],
 | 
				
			||||||
@@ -159,7 +169,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
        Weekmail().save()
 | 
					        Weekmail().save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
 | 
					        # Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
 | 
				
			||||||
        self.now = timezone.now().replace(hour=12)
 | 
					        self.now = timezone.now().replace(hour=12, second=0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        skia = User.objects.create_user(
 | 
					        skia = User.objects.create_user(
 | 
				
			||||||
            username="skia",
 | 
					            username="skia",
 | 
				
			||||||
@@ -460,6 +470,7 @@ Welcome to the wiki page!
 | 
				
			|||||||
            limit_age=18,
 | 
					            limit_age=18,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        cons = Product.objects.create(
 | 
					        cons = Product.objects.create(
 | 
				
			||||||
 | 
					            id=settings.SITH_ECOCUP_CONS,
 | 
				
			||||||
            name="Consigne Eco-cup",
 | 
					            name="Consigne Eco-cup",
 | 
				
			||||||
            code="CONS",
 | 
					            code="CONS",
 | 
				
			||||||
            product_type=verre,
 | 
					            product_type=verre,
 | 
				
			||||||
@@ -469,6 +480,7 @@ Welcome to the wiki page!
 | 
				
			|||||||
            club=main_club,
 | 
					            club=main_club,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        dcons = Product.objects.create(
 | 
					        dcons = Product.objects.create(
 | 
				
			||||||
 | 
					            id=settings.SITH_ECOCUP_DECO,
 | 
				
			||||||
            name="Déconsigne Eco-cup",
 | 
					            name="Déconsigne Eco-cup",
 | 
				
			||||||
            code="DECO",
 | 
					            code="DECO",
 | 
				
			||||||
            product_type=verre,
 | 
					            product_type=verre,
 | 
				
			||||||
@@ -669,17 +681,16 @@ Welcome to the wiki page!
 | 
				
			|||||||
        friday = self.now
 | 
					        friday = self.now
 | 
				
			||||||
        while friday.weekday() != 4:
 | 
					        while friday.weekday() != 4:
 | 
				
			||||||
            friday += timedelta(hours=6)
 | 
					            friday += timedelta(hours=6)
 | 
				
			||||||
        friday.replace(hour=20, minute=0, second=0)
 | 
					        friday.replace(hour=20, minute=0)
 | 
				
			||||||
        # Event
 | 
					        # Event
 | 
				
			||||||
        news_dates = []
 | 
					        news_dates = []
 | 
				
			||||||
        n = News.objects.create(
 | 
					        n = News.objects.create(
 | 
				
			||||||
            title="Apero barman",
 | 
					            title="Apero barman",
 | 
				
			||||||
            summary="Viens boire un coup avec les barmans",
 | 
					            summary="Viens boire un coup avec les barmans",
 | 
				
			||||||
            content="Glou glou glou glou glou glou glou",
 | 
					            content="Glou glou glou glou glou glou glou",
 | 
				
			||||||
            type="EVENT",
 | 
					 | 
				
			||||||
            club=bar_club,
 | 
					            club=bar_club,
 | 
				
			||||||
            author=subscriber,
 | 
					            author=subscriber,
 | 
				
			||||||
            is_moderated=True,
 | 
					            is_published=True,
 | 
				
			||||||
            moderator=skia,
 | 
					            moderator=skia,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        news_dates.append(
 | 
					        news_dates.append(
 | 
				
			||||||
@@ -693,13 +704,11 @@ Welcome to the wiki page!
 | 
				
			|||||||
            title="Repas barman",
 | 
					            title="Repas barman",
 | 
				
			||||||
            summary="Enjoy la fin du semestre!",
 | 
					            summary="Enjoy la fin du semestre!",
 | 
				
			||||||
            content=(
 | 
					            content=(
 | 
				
			||||||
                "Viens donc t'enjailler avec les autres barmans aux "
 | 
					                "Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
 | 
				
			||||||
                "frais du BdF! \\o/"
 | 
					 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            type="EVENT",
 | 
					 | 
				
			||||||
            club=bar_club,
 | 
					            club=bar_club,
 | 
				
			||||||
            author=subscriber,
 | 
					            author=subscriber,
 | 
				
			||||||
            is_moderated=True,
 | 
					            is_published=True,
 | 
				
			||||||
            moderator=skia,
 | 
					            moderator=skia,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        news_dates.append(
 | 
					        news_dates.append(
 | 
				
			||||||
@@ -713,10 +722,9 @@ Welcome to the wiki page!
 | 
				
			|||||||
            title="Repas fromager",
 | 
					            title="Repas fromager",
 | 
				
			||||||
            summary="Wien manger du l'bon fromeug'",
 | 
					            summary="Wien manger du l'bon fromeug'",
 | 
				
			||||||
            content="Fô viendre mangey d'la bonne fondue!",
 | 
					            content="Fô viendre mangey d'la bonne fondue!",
 | 
				
			||||||
            type="EVENT",
 | 
					 | 
				
			||||||
            club=bar_club,
 | 
					            club=bar_club,
 | 
				
			||||||
            author=subscriber,
 | 
					            author=subscriber,
 | 
				
			||||||
            is_moderated=True,
 | 
					            is_published=True,
 | 
				
			||||||
            moderator=skia,
 | 
					            moderator=skia,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        news_dates.append(
 | 
					        news_dates.append(
 | 
				
			||||||
@@ -730,10 +738,9 @@ Welcome to the wiki page!
 | 
				
			|||||||
            title="SdF",
 | 
					            title="SdF",
 | 
				
			||||||
            summary="Enjoy la fin des finaux!",
 | 
					            summary="Enjoy la fin des finaux!",
 | 
				
			||||||
            content="Viens faire la fête avec tout plein de gens!",
 | 
					            content="Viens faire la fête avec tout plein de gens!",
 | 
				
			||||||
            type="EVENT",
 | 
					 | 
				
			||||||
            club=bar_club,
 | 
					            club=bar_club,
 | 
				
			||||||
            author=subscriber,
 | 
					            author=subscriber,
 | 
				
			||||||
            is_moderated=True,
 | 
					            is_published=True,
 | 
				
			||||||
            moderator=skia,
 | 
					            moderator=skia,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        news_dates.append(
 | 
					        news_dates.append(
 | 
				
			||||||
@@ -749,10 +756,9 @@ Welcome to the wiki page!
 | 
				
			|||||||
            summary="Viens jouer!",
 | 
					            summary="Viens jouer!",
 | 
				
			||||||
            content="Rejoins la fine équipe du Troll Penché et viens "
 | 
					            content="Rejoins la fine équipe du Troll Penché et viens "
 | 
				
			||||||
            "t'amuser le Vendredi soir!",
 | 
					            "t'amuser le Vendredi soir!",
 | 
				
			||||||
            type="WEEKLY",
 | 
					 | 
				
			||||||
            club=troll,
 | 
					            club=troll,
 | 
				
			||||||
            author=subscriber,
 | 
					            author=subscriber,
 | 
				
			||||||
            is_moderated=True,
 | 
					            is_published=True,
 | 
				
			||||||
            moderator=skia,
 | 
					            moderator=skia,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        news_dates.extend(
 | 
					        news_dates.extend(
 | 
				
			||||||
@@ -897,11 +903,17 @@ Welcome to the wiki page!
 | 
				
			|||||||
        public_group = Group.objects.create(name="Public")
 | 
					        public_group = Group.objects.create(name="Public")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        subscribers = Group.objects.create(name="Subscribers")
 | 
					        subscribers = Group.objects.create(name="Subscribers")
 | 
				
			||||||
 | 
					        subscribers.permissions.add(
 | 
				
			||||||
 | 
					            *list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        old_subscribers = Group.objects.create(name="Old subscribers")
 | 
					        old_subscribers = Group.objects.create(name="Old subscribers")
 | 
				
			||||||
        old_subscribers.permissions.add(
 | 
					        old_subscribers.permissions.add(
 | 
				
			||||||
            *list(
 | 
					            *list(
 | 
				
			||||||
                perms.filter(
 | 
					                perms.filter(
 | 
				
			||||||
                    codename__in=[
 | 
					                    codename__in=[
 | 
				
			||||||
 | 
					                        "view_uv",
 | 
				
			||||||
 | 
					                        "view_uvcomment",
 | 
				
			||||||
 | 
					                        "add_uvcommentreport",
 | 
				
			||||||
                        "view_user",
 | 
					                        "view_user",
 | 
				
			||||||
                        "view_picture",
 | 
					                        "view_picture",
 | 
				
			||||||
                        "view_album",
 | 
					                        "view_album",
 | 
				
			||||||
@@ -973,9 +985,9 @@ Welcome to the wiki page!
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        pedagogy_admin.permissions.add(
 | 
					        pedagogy_admin.permissions.add(
 | 
				
			||||||
            *list(
 | 
					            *list(
 | 
				
			||||||
                perms.filter(content_type__app_label="pedagogy").values_list(
 | 
					                perms.filter(content_type__app_label="pedagogy")
 | 
				
			||||||
                    "pk", flat=True
 | 
					                .exclude(codename__in=["change_uvcomment"])
 | 
				
			||||||
                )
 | 
					                .values_list("pk", flat=True)
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.reset_index("core", "auth")
 | 
					        self.reset_index("core", "auth")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,6 +5,7 @@ from typing import Iterator
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from dateutil.relativedelta import relativedelta
 | 
					from dateutil.relativedelta import relativedelta
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.contrib.auth.hashers import make_password
 | 
				
			||||||
from django.core.management.base import BaseCommand
 | 
					from django.core.management.base import BaseCommand
 | 
				
			||||||
from django.db.models import Count, Exists, Min, OuterRef, Subquery
 | 
					from django.db.models import Count, Exists, Min, OuterRef, Subquery
 | 
				
			||||||
from django.utils.timezone import localdate, make_aware, now
 | 
					from django.utils.timezone import localdate, make_aware, now
 | 
				
			||||||
@@ -38,26 +39,10 @@ class Command(BaseCommand):
 | 
				
			|||||||
            raise Exception("Never call this command in prod. Never.")
 | 
					            raise Exception("Never call this command in prod. Never.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.stdout.write("Creating users...")
 | 
					        self.stdout.write("Creating users...")
 | 
				
			||||||
        users = [
 | 
					        users = self.create_users()
 | 
				
			||||||
            User(
 | 
					 | 
				
			||||||
                username=self.faker.user_name(),
 | 
					 | 
				
			||||||
                first_name=self.faker.first_name(),
 | 
					 | 
				
			||||||
                last_name=self.faker.last_name(),
 | 
					 | 
				
			||||||
                date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
 | 
					 | 
				
			||||||
                email=self.faker.email(),
 | 
					 | 
				
			||||||
                phone=self.faker.phone_number(),
 | 
					 | 
				
			||||||
                address=self.faker.address(),
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            for _ in range(600)
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
        # there may a duplicate or two
 | 
					 | 
				
			||||||
        # Not a problem, we will just have 599 users instead of 600
 | 
					 | 
				
			||||||
        User.objects.bulk_create(users, ignore_conflicts=True)
 | 
					 | 
				
			||||||
        users = list(User.objects.order_by("-id")[: len(users)])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        subscribers = random.sample(users, k=int(0.8 * len(users)))
 | 
					        subscribers = random.sample(users, k=int(0.8 * len(users)))
 | 
				
			||||||
        self.stdout.write("Creating subscriptions...")
 | 
					        self.stdout.write("Creating subscriptions...")
 | 
				
			||||||
        self.create_subscriptions(users)
 | 
					        self.create_subscriptions(subscribers)
 | 
				
			||||||
        self.stdout.write("Creating club memberships...")
 | 
					        self.stdout.write("Creating club memberships...")
 | 
				
			||||||
        users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
 | 
					        users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
 | 
				
			||||||
        subscribers_now = list(
 | 
					        subscribers_now = list(
 | 
				
			||||||
@@ -102,11 +87,34 @@ class Command(BaseCommand):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        self.stdout.write("Done")
 | 
					        self.stdout.write("Done")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def create_users(self) -> list[User]:
 | 
				
			||||||
 | 
					        password = make_password("plop")
 | 
				
			||||||
 | 
					        users = [
 | 
				
			||||||
 | 
					            User(
 | 
				
			||||||
 | 
					                username=self.faker.user_name(),
 | 
				
			||||||
 | 
					                first_name=self.faker.first_name(),
 | 
				
			||||||
 | 
					                last_name=self.faker.last_name(),
 | 
				
			||||||
 | 
					                date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
 | 
				
			||||||
 | 
					                email=self.faker.email(),
 | 
				
			||||||
 | 
					                phone=self.faker.phone_number(),
 | 
				
			||||||
 | 
					                address=self.faker.address(),
 | 
				
			||||||
 | 
					                password=password,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            for _ in range(600)
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        # there may a duplicate or two
 | 
				
			||||||
 | 
					        # Not a problem, we will just have 599 users instead of 600
 | 
				
			||||||
 | 
					        users = User.objects.bulk_create(users, ignore_conflicts=True)
 | 
				
			||||||
 | 
					        users = list(User.objects.order_by("-id")[: len(users)])
 | 
				
			||||||
 | 
					        public_group = Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID)
 | 
				
			||||||
 | 
					        public_group.users.add(*users)
 | 
				
			||||||
 | 
					        return users
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create_subscriptions(self, users: list[User]):
 | 
					    def create_subscriptions(self, users: list[User]):
 | 
				
			||||||
        def prepare_subscription(user: User, start_date: date) -> Subscription:
 | 
					        def prepare_subscription(_user: User, start_date: date) -> Subscription:
 | 
				
			||||||
            payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
 | 
					            payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
 | 
				
			||||||
            duration = random.randint(1, 4)
 | 
					            duration = random.randint(1, 4)
 | 
				
			||||||
            sub = Subscription(member=user, payment_method=payment_method)
 | 
					            sub = Subscription(member=_user, payment_method=payment_method)
 | 
				
			||||||
            sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
 | 
					            sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
 | 
				
			||||||
            sub.subscription_end = sub.compute_end(duration)
 | 
					            sub.subscription_end = sub.compute_end(duration)
 | 
				
			||||||
            return sub
 | 
					            return sub
 | 
				
			||||||
@@ -130,6 +138,10 @@ class Command(BaseCommand):
 | 
				
			|||||||
                    user, self.faker.past_date(sub.subscription_end)
 | 
					                    user, self.faker.past_date(sub.subscription_end)
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                subscriptions.append(sub)
 | 
					                subscriptions.append(sub)
 | 
				
			||||||
 | 
					        old_subscriber_group = Group.objects.get(
 | 
				
			||||||
 | 
					            pk=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        old_subscriber_group.users.add(*users)
 | 
				
			||||||
        Subscription.objects.bulk_create(subscriptions)
 | 
					        Subscription.objects.bulk_create(subscriptions)
 | 
				
			||||||
        Customer.objects.bulk_create(customers, ignore_conflicts=True)
 | 
					        Customer.objects.bulk_create(customers, ignore_conflicts=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										16
									
								
								core/migrations/0044_alter_userban_options.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								core/migrations/0044_alter_userban_options.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.2.17 on 2025-02-25 14:45
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("core", "0043_bangroup_alter_group_description_alter_user_groups_and_more"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterModelOptions(
 | 
				
			||||||
 | 
					            name="userban",
 | 
				
			||||||
 | 
					            options={"verbose_name": "user ban", "verbose_name_plural": "user bans"},
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -29,6 +29,7 @@ import os
 | 
				
			|||||||
import string
 | 
					import string
 | 
				
			||||||
import unicodedata
 | 
					import unicodedata
 | 
				
			||||||
from datetime import timedelta
 | 
					from datetime import timedelta
 | 
				
			||||||
 | 
					from io import BytesIO
 | 
				
			||||||
from pathlib import Path
 | 
					from pathlib import Path
 | 
				
			||||||
from typing import TYPE_CHECKING, Optional, Self
 | 
					from typing import TYPE_CHECKING, Optional, Self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -50,6 +51,7 @@ from django.utils.html import escape
 | 
				
			|||||||
from django.utils.timezone import localdate, now
 | 
					from django.utils.timezone import localdate, now
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from phonenumber_field.modelfields import PhoneNumberField
 | 
					from phonenumber_field.modelfields import PhoneNumberField
 | 
				
			||||||
 | 
					from PIL import Image
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if TYPE_CHECKING:
 | 
					if TYPE_CHECKING:
 | 
				
			||||||
    from pydantic import NonNegativeInt
 | 
					    from pydantic import NonNegativeInt
 | 
				
			||||||
@@ -320,12 +322,16 @@ class User(AbstractUser):
 | 
				
			|||||||
        return self.get_display_name()
 | 
					        return self.get_display_name()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        adding = self._state.adding
 | 
				
			||||||
        with transaction.atomic():
 | 
					        with transaction.atomic():
 | 
				
			||||||
            if self.id:
 | 
					            if not adding:
 | 
				
			||||||
                old = User.objects.filter(id=self.id).first()
 | 
					                old = User.objects.filter(id=self.id).first()
 | 
				
			||||||
                if old and old.username != self.username:
 | 
					                if old and old.username != self.username:
 | 
				
			||||||
                    self._change_username(self.username)
 | 
					                    self._change_username(self.username)
 | 
				
			||||||
            super().save(*args, **kwargs)
 | 
					            super().save(*args, **kwargs)
 | 
				
			||||||
 | 
					            if adding:
 | 
				
			||||||
 | 
					                # All users are in the public group.
 | 
				
			||||||
 | 
					                self.groups.add(settings.SITH_GROUP_PUBLIC_ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_absolute_url(self) -> str:
 | 
					    def get_absolute_url(self) -> str:
 | 
				
			||||||
        return reverse("core:user_profile", kwargs={"user_id": self.pk})
 | 
					        return reverse("core:user_profile", kwargs={"user_id": self.pk})
 | 
				
			||||||
@@ -380,12 +386,8 @@ class User(AbstractUser):
 | 
				
			|||||||
            raise ValueError("You must either provide the id or the name of the group")
 | 
					            raise ValueError("You must either provide the id or the name of the group")
 | 
				
			||||||
        if group is None:
 | 
					        if group is None:
 | 
				
			||||||
            return False
 | 
					            return False
 | 
				
			||||||
        if group.id == settings.SITH_GROUP_PUBLIC_ID:
 | 
					 | 
				
			||||||
            return True
 | 
					 | 
				
			||||||
        if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
 | 
					        if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
 | 
				
			||||||
            return self.is_subscribed
 | 
					            return self.is_subscribed
 | 
				
			||||||
        if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:
 | 
					 | 
				
			||||||
            return self.was_subscribed
 | 
					 | 
				
			||||||
        if group.id == settings.SITH_GROUP_ROOT_ID:
 | 
					        if group.id == settings.SITH_GROUP_ROOT_ID:
 | 
				
			||||||
            return self.is_root
 | 
					            return self.is_root
 | 
				
			||||||
        return group in self.cached_groups
 | 
					        return group in self.cached_groups
 | 
				
			||||||
@@ -415,29 +417,6 @@ class User(AbstractUser):
 | 
				
			|||||||
    def is_board_member(self) -> bool:
 | 
					    def is_board_member(self) -> bool:
 | 
				
			||||||
        return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
 | 
					        return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @cached_property
 | 
					 | 
				
			||||||
    def can_read_subscription_history(self) -> bool:
 | 
					 | 
				
			||||||
        if self.is_root or self.is_board_member:
 | 
					 | 
				
			||||||
            return True
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        from club.models import Club
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        for club in Club.objects.filter(
 | 
					 | 
				
			||||||
            id__in=settings.SITH_CAN_READ_SUBSCRIPTION_HISTORY
 | 
					 | 
				
			||||||
        ):
 | 
					 | 
				
			||||||
            if club in self.clubs_with_rights:
 | 
					 | 
				
			||||||
                return True
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @cached_property
 | 
					 | 
				
			||||||
    def can_create_subscription(self) -> bool:
 | 
					 | 
				
			||||||
        return self.is_root or (
 | 
					 | 
				
			||||||
            self.memberships.board()
 | 
					 | 
				
			||||||
            .ongoing()
 | 
					 | 
				
			||||||
            .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
 | 
					 | 
				
			||||||
            .exists()
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @cached_property
 | 
					    @cached_property
 | 
				
			||||||
    def is_launderette_manager(self):
 | 
					    def is_launderette_manager(self):
 | 
				
			||||||
        from club.models import Club
 | 
					        from club.models import Club
 | 
				
			||||||
@@ -677,14 +656,6 @@ class AnonymousUser(AuthAnonymousUser):
 | 
				
			|||||||
    def __init__(self):
 | 
					    def __init__(self):
 | 
				
			||||||
        super().__init__()
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def can_create_subscription(self):
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def can_read_subscription_history(self):
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def was_subscribed(self):
 | 
					    def was_subscribed(self):
 | 
				
			||||||
        return False
 | 
					        return False
 | 
				
			||||||
@@ -988,17 +959,11 @@ class SithFile(models.Model):
 | 
				
			|||||||
        if self.is_folder:
 | 
					        if self.is_folder:
 | 
				
			||||||
            if self.file:
 | 
					            if self.file:
 | 
				
			||||||
                try:
 | 
					                try:
 | 
				
			||||||
                    import imghdr
 | 
					                    Image.open(BytesIO(self.file.read()))
 | 
				
			||||||
 | 
					                except Image.UnidentifiedImageError as e:
 | 
				
			||||||
                    if imghdr.what(None, self.file.read()) not in [
 | 
					                    raise ValidationError(
 | 
				
			||||||
                        "gif",
 | 
					                        _("This is not a valid folder thumbnail")
 | 
				
			||||||
                        "png",
 | 
					                    ) from e
 | 
				
			||||||
                        "jpeg",
 | 
					 | 
				
			||||||
                    ]:
 | 
					 | 
				
			||||||
                        self.file.delete()
 | 
					 | 
				
			||||||
                        self.file = None
 | 
					 | 
				
			||||||
                except:  # noqa E722 I don't know the exception that can be raised
 | 
					 | 
				
			||||||
                    self.file = None
 | 
					 | 
				
			||||||
            self.mime_type = "inode/directory"
 | 
					            self.mime_type = "inode/directory"
 | 
				
			||||||
        if self.is_file and (self.file is None or self.file == ""):
 | 
					        if self.is_file and (self.file is None or self.file == ""):
 | 
				
			||||||
            raise ValidationError(_("You must provide a file"))
 | 
					            raise ValidationError(_("You must provide a file"))
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										73
									
								
								core/static/bundled/core/read-more-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								core/static/bundled/core/read-more-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,73 @@
 | 
				
			|||||||
 | 
					import clip from "@arendjr/text-clipper";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*
 | 
				
			||||||
 | 
						This script adds a way to have a 'show more / show less' button
 | 
				
			||||||
 | 
						on some text content.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						The usage is very simple, you just have to add the attribute `show-more`
 | 
				
			||||||
 | 
						with the desired max size to the element you want to add the button to.
 | 
				
			||||||
 | 
						This script does html matching and is able to properly cut rendered markdown.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						Example usage:
 | 
				
			||||||
 | 
							<p show-more="20">
 | 
				
			||||||
 | 
								My very long text will be cut by this script
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function showMore(element: HTMLElement) {
 | 
				
			||||||
 | 
					  if (!element.hasAttribute("show-more")) {
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Mark element as loaded so we can hide unloaded
 | 
				
			||||||
 | 
					  // tags with css and avoid blinking text
 | 
				
			||||||
 | 
					  element.setAttribute("show-more-loaded", "");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fullContent = element.innerHTML;
 | 
				
			||||||
 | 
					  const clippedContent = clip(
 | 
				
			||||||
 | 
					    element.innerHTML,
 | 
				
			||||||
 | 
					    Number.parseInt(element.getAttribute("show-more") as string),
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      html: true,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // If already at the desired size, we don't do anything
 | 
				
			||||||
 | 
					  if (clippedContent === fullContent) {
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const actionLink = document.createElement("a");
 | 
				
			||||||
 | 
					  actionLink.setAttribute("class", "show-more-link");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let opened = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const setText = () => {
 | 
				
			||||||
 | 
					    if (opened) {
 | 
				
			||||||
 | 
					      element.innerHTML = fullContent;
 | 
				
			||||||
 | 
					      actionLink.innerText = gettext("Show less");
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      element.innerHTML = clippedContent;
 | 
				
			||||||
 | 
					      actionLink.innerText = gettext("Show more");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    element.appendChild(document.createElement("br"));
 | 
				
			||||||
 | 
					    element.appendChild(actionLink);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const toggle = () => {
 | 
				
			||||||
 | 
					    opened = !opened;
 | 
				
			||||||
 | 
					    setText();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setText();
 | 
				
			||||||
 | 
					  actionLink.addEventListener("click", (event) => {
 | 
				
			||||||
 | 
					    event.preventDefault();
 | 
				
			||||||
 | 
					    toggle();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					document.addEventListener("DOMContentLoaded", () => {
 | 
				
			||||||
 | 
					  for (const elem of document.querySelectorAll("[show-more]")) {
 | 
				
			||||||
 | 
					    showMore(elem as HTMLElement);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										3
									
								
								core/static/bundled/country-flags-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								core/static/bundled/country-flags-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					polyfillCountryFlagEmojis();
 | 
				
			||||||
@@ -1,101 +0,0 @@
 | 
				
			|||||||
import { paginated } from "#core:utils/api";
 | 
					 | 
				
			||||||
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
 | 
					 | 
				
			||||||
import { showSaveFilePicker } from "native-file-system-adapter";
 | 
					 | 
				
			||||||
import { picturesFetchPictures } from "#openapi";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * @typedef UserProfile
 | 
					 | 
				
			||||||
 * @property {number} id
 | 
					 | 
				
			||||||
 * @property {string} first_name
 | 
					 | 
				
			||||||
 * @property {string} last_name
 | 
					 | 
				
			||||||
 * @property {string} nick_name
 | 
					 | 
				
			||||||
 * @property {string} display_name
 | 
					 | 
				
			||||||
 * @property {string} profile_url
 | 
					 | 
				
			||||||
 * @property {string} profile_pict
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * @typedef Picture
 | 
					 | 
				
			||||||
 * @property {number} id
 | 
					 | 
				
			||||||
 * @property {string} name
 | 
					 | 
				
			||||||
 * @property {number} size
 | 
					 | 
				
			||||||
 * @property {string} date
 | 
					 | 
				
			||||||
 * @property {UserProfile} owner
 | 
					 | 
				
			||||||
 * @property {string} full_size_url
 | 
					 | 
				
			||||||
 * @property {string} compressed_url
 | 
					 | 
				
			||||||
 * @property {string} thumb_url
 | 
					 | 
				
			||||||
 * @property {string} album
 | 
					 | 
				
			||||||
 * @property {boolean} is_moderated
 | 
					 | 
				
			||||||
 * @property {boolean} asked_for_removal
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * @typedef PicturePageConfig
 | 
					 | 
				
			||||||
 * @property {number} userId Id of the user to get the pictures from
 | 
					 | 
				
			||||||
 **/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * Load user picture page with a nice download bar
 | 
					 | 
				
			||||||
 * @param {PicturePageConfig} config
 | 
					 | 
				
			||||||
 **/
 | 
					 | 
				
			||||||
window.loadPicturePage = (config) => {
 | 
					 | 
				
			||||||
  document.addEventListener("alpine:init", () => {
 | 
					 | 
				
			||||||
    Alpine.data("user_pictures", () => ({
 | 
					 | 
				
			||||||
      isDownloading: false,
 | 
					 | 
				
			||||||
      loading: true,
 | 
					 | 
				
			||||||
      pictures: [],
 | 
					 | 
				
			||||||
      albums: {},
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      async init() {
 | 
					 | 
				
			||||||
        this.pictures = await paginated(picturesFetchPictures, {
 | 
					 | 
				
			||||||
          // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
					 | 
				
			||||||
          query: { users_identified: [config.userId] },
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        this.albums = this.pictures.reduce((acc, picture) => {
 | 
					 | 
				
			||||||
          if (!acc[picture.album]) {
 | 
					 | 
				
			||||||
            acc[picture.album] = [];
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          acc[picture.album].push(picture);
 | 
					 | 
				
			||||||
          return acc;
 | 
					 | 
				
			||||||
        }, {});
 | 
					 | 
				
			||||||
        this.loading = false;
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      async downloadZip() {
 | 
					 | 
				
			||||||
        this.isDownloading = true;
 | 
					 | 
				
			||||||
        const bar = this.$refs.progress;
 | 
					 | 
				
			||||||
        bar.value = 0;
 | 
					 | 
				
			||||||
        bar.max = this.pictures.length;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const incrementProgressBar = () => {
 | 
					 | 
				
			||||||
          bar.value++;
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const fileHandle = await showSaveFilePicker({
 | 
					 | 
				
			||||||
          _preferPolyfill: false,
 | 
					 | 
				
			||||||
          suggestedName: interpolate(
 | 
					 | 
				
			||||||
            gettext("pictures.%(extension)s"),
 | 
					 | 
				
			||||||
            { extension: "zip" },
 | 
					 | 
				
			||||||
            true,
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
          types: {},
 | 
					 | 
				
			||||||
          excludeAcceptAllOption: false,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        const zipWriter = new ZipWriter(await fileHandle.createWritable());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await Promise.all(
 | 
					 | 
				
			||||||
          this.pictures.map((p) => {
 | 
					 | 
				
			||||||
            const imgName = `${p.album}/IMG_${p.date.replaceAll(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
 | 
					 | 
				
			||||||
            return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
 | 
					 | 
				
			||||||
              level: 9,
 | 
					 | 
				
			||||||
              lastModDate: new Date(p.date),
 | 
					 | 
				
			||||||
              onstart: incrementProgressBar,
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
          }),
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await zipWriter.close();
 | 
					 | 
				
			||||||
        this.isDownloading = false;
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    }));
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
@@ -1,11 +1,27 @@
 | 
				
			|||||||
 | 
					.ts-wrapper.multi .ts-control {
 | 
				
			||||||
 | 
					  min-width: calc(100% - 0.2rem);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* This also requires ajax-select-index.css */
 | 
					/* This also requires ajax-select-index.css */
 | 
				
			||||||
.ts-dropdown {
 | 
					.ts-dropdown {
 | 
				
			||||||
 | 
					  width: calc(100% - 0.2rem);
 | 
				
			||||||
 | 
					  left: 0.1rem;
 | 
				
			||||||
 | 
					  top: calc(100% - 0.2rem - var(--nf-input-border-bottom-width));
 | 
				
			||||||
 | 
					  border: var(--nf-input-border-color) var(--nf-input-border-width) solid;
 | 
				
			||||||
 | 
					  border-top: none;
 | 
				
			||||||
 | 
					  border-bottom-width: var(--nf-input-border-bottom-width);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .option.active {
 | 
				
			||||||
 | 
					    background-color: #e5eafa;
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .select-item {
 | 
					  .select-item {
 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
    flex-direction: row;
 | 
					    flex-direction: row;
 | 
				
			||||||
    gap: 10px;
 | 
					    gap: 10px;
 | 
				
			||||||
    align-items: center;
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    img {
 | 
					    img {
 | 
				
			||||||
      height: 40px;
 | 
					      height: 40px;
 | 
				
			||||||
@@ -16,19 +32,44 @@
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ts-wrapper {
 | 
					.ts-wrapper.single {
 | 
				
			||||||
  margin: 5px;
 | 
					  > .ts-control {
 | 
				
			||||||
 | 
					    box-shadow: none;
 | 
				
			||||||
 | 
					    max-width: 300px;
 | 
				
			||||||
 | 
					    background-color: var(--nf-input-background-color);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &::after {
 | 
				
			||||||
 | 
					      content: none;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  > .ts-dropdown {
 | 
				
			||||||
 | 
					    max-width: 300px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ts-wrapper.single {
 | 
					.ts-wrapper input[type="text"] {
 | 
				
			||||||
  width: 263px; // same length as regular text inputs
 | 
					  border: none;
 | 
				
			||||||
 | 
					  border-radius: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.ts-wrapper.multi, .ts-wrapper.single {
 | 
				
			||||||
 | 
					  .ts-control:has(input:focus) {
 | 
				
			||||||
 | 
					    outline: none;
 | 
				
			||||||
 | 
					    border-color: var(--nf-input-focus-border-color);
 | 
				
			||||||
 | 
					    box-shadow: none;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
 | 
					.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
 | 
				
			||||||
  border-left: 1px solid #aaa;
 | 
					  border-left: 1px solid #aaa;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ts-wrapper.multi .ts-control {
 | 
					.ts-wrapper.multi.has-items .ts-control {
 | 
				
			||||||
 | 
					  padding: calc(var(--nf-input-size) * 0.65);
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: calc(var(--nf-input-size) / 3);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  [data-value],
 | 
					  [data-value],
 | 
				
			||||||
  [data-value].active {
 | 
					  [data-value].active {
 | 
				
			||||||
    background-image: none;
 | 
					    background-image: none;
 | 
				
			||||||
@@ -37,19 +78,17 @@
 | 
				
			|||||||
    border: 1px solid #aaa;
 | 
					    border: 1px solid #aaa;
 | 
				
			||||||
    border-radius: 4px;
 | 
					    border-radius: 4px;
 | 
				
			||||||
    display: inline-block;
 | 
					    display: inline-block;
 | 
				
			||||||
    margin-left: 5px;
 | 
					 | 
				
			||||||
    margin-top: 5px;
 | 
					 | 
				
			||||||
    margin-bottom: 5px;
 | 
					 | 
				
			||||||
    padding-right: 10px;
 | 
					    padding-right: 10px;
 | 
				
			||||||
    padding-left: 10px;
 | 
					    padding-left: 10px;
 | 
				
			||||||
    text-shadow: none;
 | 
					    text-shadow: none;
 | 
				
			||||||
    box-shadow: none;
 | 
					    box-shadow: none;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .remove {
 | 
				
			||||||
 | 
					      vertical-align: baseline;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.ts-dropdown {
 | 
					.ts-wrapper.focus .ts-control {
 | 
				
			||||||
  .option.active {
 | 
					  box-shadow: none;
 | 
				
			||||||
    background-color: #e5eafa;
 | 
					 | 
				
			||||||
    color: inherit;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -48,7 +48,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  input,
 | 
					  input,
 | 
				
			||||||
  textarea[type="text"],
 | 
					  textarea[type="text"],
 | 
				
			||||||
  [type="number"] {
 | 
					  [type="number"],
 | 
				
			||||||
 | 
					  .ts-control {
 | 
				
			||||||
    border: none;
 | 
					    border: none;
 | 
				
			||||||
    text-decoration: none;
 | 
					    text-decoration: none;
 | 
				
			||||||
    background-color: $background-button-color;
 | 
					    background-color: $background-button-color;
 | 
				
			||||||
@@ -69,7 +70,7 @@
 | 
				
			|||||||
    font-family: sans-serif;
 | 
					    font-family: sans-serif;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  select {
 | 
					  select, .ts-control {
 | 
				
			||||||
    border: none;
 | 
					    border: none;
 | 
				
			||||||
    text-decoration: none;
 | 
					    text-decoration: none;
 | 
				
			||||||
    font-size: 1.2em;
 | 
					    font-size: 1.2em;
 | 
				
			||||||
@@ -177,7 +178,7 @@ form {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // wrap texts
 | 
					  // wrap texts
 | 
				
			||||||
  label, legend, ul.errorlist>li, .helptext {
 | 
					  label, legend, ul.errorlist > li, .helptext {
 | 
				
			||||||
    text-wrap: wrap;
 | 
					    text-wrap: wrap;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -218,23 +219,25 @@ form {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  input[type="text"],
 | 
					  :not(.ts-control) > {
 | 
				
			||||||
  input[type="email"],
 | 
					    input[type="text"],
 | 
				
			||||||
  input[type="tel"],
 | 
					    input[type="email"],
 | 
				
			||||||
  input[type="url"],
 | 
					    input[type="tel"],
 | 
				
			||||||
  input[type="password"],
 | 
					    input[type="url"],
 | 
				
			||||||
  input[type="number"],
 | 
					    input[type="password"],
 | 
				
			||||||
  input[type="date"],
 | 
					    input[type="number"],
 | 
				
			||||||
  input[type="week"],
 | 
					    input[type="date"],
 | 
				
			||||||
  input[type="time"],
 | 
					    input[type="week"],
 | 
				
			||||||
  input[type="month"],
 | 
					    input[type="time"],
 | 
				
			||||||
  input[type="search"],
 | 
					    input[type="search"],
 | 
				
			||||||
  textarea,
 | 
					    textarea,
 | 
				
			||||||
  select {
 | 
					    input[type="month"],
 | 
				
			||||||
    min-width: 300px;
 | 
					    select {
 | 
				
			||||||
 | 
					      min-width: 300px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    &.grow {
 | 
					      &.grow {
 | 
				
			||||||
      width: 95%;
 | 
					        width: 95%;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -253,7 +256,8 @@ form {
 | 
				
			|||||||
  input[type="month"],
 | 
					  input[type="month"],
 | 
				
			||||||
  input[type="search"],
 | 
					  input[type="search"],
 | 
				
			||||||
  textarea,
 | 
					  textarea,
 | 
				
			||||||
  select {
 | 
					  select,
 | 
				
			||||||
 | 
					  .ts-control {
 | 
				
			||||||
    background: var(--nf-input-background-color);
 | 
					    background: var(--nf-input-background-color);
 | 
				
			||||||
    font-size: var(--nf-input-font-size);
 | 
					    font-size: var(--nf-input-font-size);
 | 
				
			||||||
    border-color: var(--nf-input-border-color);
 | 
					    border-color: var(--nf-input-border-color);
 | 
				
			||||||
@@ -661,7 +665,9 @@ form {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    &:checked {
 | 
					    &:checked {
 | 
				
			||||||
      background: var(--nf-input-focus-border-color) none initial;
 | 
					      background: none;
 | 
				
			||||||
 | 
					      background-position: 0 0;
 | 
				
			||||||
 | 
					      background-color: var(--nf-input-focus-border-color);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      &::after {
 | 
					      &::after {
 | 
				
			||||||
        transform: translateY(-50%) translateX(
 | 
					        transform: translateY(-50%) translateX(
 | 
				
			||||||
@@ -713,7 +719,11 @@ form {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // ---------------- SELECT
 | 
					  // ---------------- SELECT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  select {
 | 
					  select,
 | 
				
			||||||
 | 
					  .ts-wrapper.multi .ts-control,
 | 
				
			||||||
 | 
					  .ts-wrapper.single .ts-control,
 | 
				
			||||||
 | 
					  .ts-wrapper.single.input-active .ts-control {
 | 
				
			||||||
 | 
					    background-color: var(--nf-input-background-color);
 | 
				
			||||||
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
 | 
					    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280'  stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
 | 
				
			||||||
    background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
 | 
					    background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
 | 
				
			||||||
    background-repeat: no-repeat;
 | 
					    background-repeat: no-repeat;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -106,6 +106,7 @@ $hovered-red-text-color: #ff4d4d;
 | 
				
			|||||||
        color: $text-color;
 | 
					        color: $text-color;
 | 
				
			||||||
        font-weight: normal;
 | 
					        font-weight: normal;
 | 
				
			||||||
        line-height: 1.3em;
 | 
					        line-height: 1.3em;
 | 
				
			||||||
 | 
					        font-family: "Twemoji Country Flags", sans-serif;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        &:hover {
 | 
					        &:hover {
 | 
				
			||||||
          background-color: $background-color-hovered;
 | 
					          background-color: $background-color-hovered;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -131,6 +131,10 @@ body {
 | 
				
			|||||||
  display: none !important;
 | 
					  display: none !important;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[show-more]:not([show-more-loaded]) {
 | 
				
			||||||
 | 
					  display: none !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/*--------------------------------HEADER-------------------------------*/
 | 
					/*--------------------------------HEADER-------------------------------*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#popupheader {
 | 
					#popupheader {
 | 
				
			||||||
@@ -240,6 +244,20 @@ body {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &.btn-green {
 | 
				
			||||||
 | 
					      $bg-color: rgba(0, 210, 83, 0.4);
 | 
				
			||||||
 | 
					      background-color: $bg-color;
 | 
				
			||||||
 | 
					      color: $black-color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:not(:disabled):hover {
 | 
				
			||||||
 | 
					        background-color: darken($bg-color, 15%);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:disabled {
 | 
				
			||||||
 | 
					        background-color: lighten($bg-color, 15%);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    &.btn-red {
 | 
					    &.btn-red {
 | 
				
			||||||
      background-color: #fc8181;
 | 
					      background-color: #fc8181;
 | 
				
			||||||
      color: black;
 | 
					      color: black;
 | 
				
			||||||
@@ -254,9 +272,26 @@ body {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    i {
 | 
					    &.btn-orange {
 | 
				
			||||||
      margin-right: 4px;
 | 
					      background-color: #fcbf81;
 | 
				
			||||||
 | 
					      color: black;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:not(:disabled):hover {
 | 
				
			||||||
 | 
					        background-color: darken(#fcbf81, 15%);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:disabled {
 | 
				
			||||||
 | 
					        background-color: lighten(#fcbf81, 15%);
 | 
				
			||||||
 | 
					        color: grey;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &:not(.btn-no-text) {
 | 
				
			||||||
 | 
					      i {
 | 
				
			||||||
 | 
					        margin-right: 4px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
@@ -429,11 +464,11 @@ body {
 | 
				
			|||||||
    flex-wrap: wrap;
 | 
					    flex-wrap: wrap;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    $col-gap: 1rem;
 | 
					    $col-gap: 1rem;
 | 
				
			||||||
    $row-gap: 0.5rem;
 | 
					    $row-gap: $col-gap / 3;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    &.gap {
 | 
					    &.gap {
 | 
				
			||||||
      column-gap: var($col-gap);
 | 
					      column-gap: $col-gap;
 | 
				
			||||||
      row-gap: var($row-gap);
 | 
					      row-gap: $row-gap;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @for $i from 2 through 5 {
 | 
					    @for $i from 2 through 5 {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,7 @@
 | 
				
			|||||||
      <script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
 | 
					      <script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
 | 
				
			||||||
      <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
 | 
					      <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
 | 
				
			||||||
      <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
 | 
					      <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
 | 
				
			||||||
 | 
					      <script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <!-- Jquery declared here to be accessible in every django widgets -->
 | 
					      <!-- Jquery declared here to be accessible in every django widgets -->
 | 
				
			||||||
      <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
					      <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
				
			||||||
@@ -125,15 +126,14 @@
 | 
				
			|||||||
          navbar.style.setProperty("display", current === "none" ? "block" : "none");
 | 
					          navbar.style.setProperty("display", current === "none" ? "block" : "none");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        $(document).keydown(function (e) {
 | 
					        document.addEventListener("keydown", (e) => {
 | 
				
			||||||
          if ($(e.target).is('input')) { return }
 | 
					          // Looking at the `s` key when not typing in a form
 | 
				
			||||||
          if ($(e.target).is('textarea')) { return }
 | 
					          if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
 | 
				
			||||||
          if ($(e.target).is('select')) { return }
 | 
					            return;
 | 
				
			||||||
          if (e.keyCode === 83) {
 | 
					 | 
				
			||||||
            $("#search").focus();
 | 
					 | 
				
			||||||
            return false;
 | 
					 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        });
 | 
					          document.getElementById("search").focus();
 | 
				
			||||||
 | 
					          e.preventDefault(); // Don't type the character in the focused search input
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
      </script>
 | 
					      </script>
 | 
				
			||||||
    {% endblock %}
 | 
					    {% endblock %}
 | 
				
			||||||
  </body>
 | 
					  </body>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,40 @@
 | 
				
			|||||||
{% extends "core/base.jinja" %}
 | 
					{% extends "core/base.jinja" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{# if the template context has the `object_name` variable,
 | 
				
			||||||
 | 
					   then this one will be used in the page title,
 | 
				
			||||||
 | 
					   instead of the result of `str(object)` #}
 | 
				
			||||||
 | 
					{% if object and not object_name %}
 | 
				
			||||||
 | 
					  {% set object_name=object %}
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block title %}
 | 
					{% block title %}
 | 
				
			||||||
  {% if object %}
 | 
					  {% if object_name %}
 | 
				
			||||||
    {% trans obj=object %}Edit {{ obj }}{% endtrans %}
 | 
					    {% trans name=object_name %}Edit {{ name }}{% endtrans %}
 | 
				
			||||||
  {% else %}
 | 
					  {% else %}
 | 
				
			||||||
    {% trans %}Save{% endtrans %}
 | 
					    {% trans %}Save{% endtrans %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  {% if object %}
 | 
					  {% if object_name %}
 | 
				
			||||||
    <h2>{% trans obj=object %}Edit {{ obj }}{% endtrans %}</h2>
 | 
					    <h2>{% trans name=object_name %}Edit {{ name }}{% endtrans %}</h2>
 | 
				
			||||||
  {% else %}
 | 
					  {% else %}
 | 
				
			||||||
    <h2>{% trans %}Save{% endtrans %}</h2>
 | 
					    <h2>{% trans %}Save{% endtrans %}</h2>
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  {% if messages %}
 | 
				
			||||||
 | 
					    <div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
 | 
				
			||||||
 | 
					      <span class="alert-main">
 | 
				
			||||||
 | 
					        {% for message in messages %}
 | 
				
			||||||
 | 
					          {% if message.level_tag == "success" %}
 | 
				
			||||||
 | 
					            {{ message }}
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					        {% endfor %}
 | 
				
			||||||
 | 
					      </span>
 | 
				
			||||||
 | 
					      <span class="clickable" @click="show_alert = false">
 | 
				
			||||||
 | 
					        <i class="fa fa-close"></i>
 | 
				
			||||||
 | 
					      </span>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
  <form action="" method="post" enctype="multipart/form-data">
 | 
					  <form action="" method="post" enctype="multipart/form-data">
 | 
				
			||||||
    {% csrf_token %}
 | 
					    {% csrf_token %}
 | 
				
			||||||
    {{ form.as_p() }}
 | 
					    {{ form.as_p() }}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -57,13 +57,4 @@
 | 
				
			|||||||
    {% endblock %}
 | 
					    {% endblock %}
 | 
				
			||||||
  {% endif %}
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {% block script %}
 | 
					 | 
				
			||||||
    {{ super() }}
 | 
					 | 
				
			||||||
    {% if popup %}
 | 
					 | 
				
			||||||
      <script>
 | 
					 | 
				
			||||||
        parent.$(".choose_file_widget").css("height", "75%");
 | 
					 | 
				
			||||||
      </script>
 | 
					 | 
				
			||||||
    {% endif %}
 | 
					 | 
				
			||||||
  {% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -39,14 +39,6 @@
 | 
				
			|||||||
  <a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
 | 
					  <a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
 | 
				
			||||||
{%- endmacro %}
 | 
					{%- endmacro %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% macro fb_quick(news) -%}
 | 
					 | 
				
			||||||
  <a rel="nofollow" target="#" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}" class="fb fa-brands fa-facebook fa-2x"></a>
 | 
					 | 
				
			||||||
{%- endmacro %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% macro tweet_quick(news) -%}
 | 
					 | 
				
			||||||
  <a rel="nofollow" target="#" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}" class="twitter fa-brands fa-twitter-square fa-2x"></a>
 | 
					 | 
				
			||||||
{%- endmacro %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% macro user_mini_profile(user) %}
 | 
					{% macro user_mini_profile(user) %}
 | 
				
			||||||
  <div class="user_mini_profile">
 | 
					  <div class="user_mini_profile">
 | 
				
			||||||
    <div class="user_mini_profile_infos">
 | 
					    <div class="user_mini_profile_infos">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -30,7 +30,7 @@
 | 
				
			|||||||
          {% if m.can_be_edited_by(user) %}
 | 
					          {% if m.can_be_edited_by(user) %}
 | 
				
			||||||
            <td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
 | 
					            <td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
 | 
				
			||||||
          {% endif %}
 | 
					          {% endif %}
 | 
				
			||||||
          {% if user.is_root %}
 | 
					          {% if user.has_perm("club.delete_membership") %}
 | 
				
			||||||
            <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
 | 
					            <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
 | 
				
			||||||
          {% endif %}
 | 
					          {% endif %}
 | 
				
			||||||
        </tr>
 | 
					        </tr>
 | 
				
			||||||
@@ -59,7 +59,7 @@
 | 
				
			|||||||
          <td>{{ m.description }}</td>
 | 
					          <td>{{ m.description }}</td>
 | 
				
			||||||
          <td>{{ m.start_date }}</td>
 | 
					          <td>{{ m.start_date }}</td>
 | 
				
			||||||
          <td>{{ m.end_date }}</td>
 | 
					          <td>{{ m.end_date }}</td>
 | 
				
			||||||
          {% if user.is_root %}
 | 
					          {% if user.has_perm("club.delete_membership") %}
 | 
				
			||||||
            <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
 | 
					            <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
 | 
				
			||||||
          {% endif %}
 | 
					          {% endif %}
 | 
				
			||||||
        </tr>
 | 
					        </tr>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -166,7 +166,7 @@
 | 
				
			|||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
<br>
 | 
					<br>
 | 
				
			||||||
{% if profile.was_subscribed and (user == profile or user.can_read_subscription_history)%}
 | 
					{% if profile.was_subscribed and (user == profile or user.has_perm("subscription.view_subscription")) %}
 | 
				
			||||||
  <div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
 | 
					  <div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
 | 
				
			||||||
    <div class="collapse-header clickable" @click="collapsed = !collapsed">
 | 
					    <div class="collapse-header clickable" @click="collapsed = !collapsed">
 | 
				
			||||||
      <span class="collapse-header-text">
 | 
					      <span class="collapse-header-text">
 | 
				
			||||||
@@ -197,9 +197,9 @@
 | 
				
			|||||||
      </table>
 | 
					      </table>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					  <hr>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<hr>
 | 
					 | 
				
			||||||
<div>
 | 
					<div>
 | 
				
			||||||
  {% if user.is_root or user.is_board_member %}
 | 
					  {% if user.is_root or user.is_board_member %}
 | 
				
			||||||
    <form class="form-gifts" action="{{ url('core:user_gift_create', user_id=profile.id) }}" method="post">
 | 
					    <form class="form-gifts" action="{{ url('core:user_gift_create', user_id=profile.id) }}" method="post">
 | 
				
			||||||
@@ -244,27 +244,30 @@
 | 
				
			|||||||
{% block script %}
 | 
					{% block script %}
 | 
				
			||||||
  {{ super() }}
 | 
					  {{ super() }}
 | 
				
			||||||
  <script>
 | 
					  <script>
 | 
				
			||||||
    $(function () {
 | 
					    // Image selection
 | 
				
			||||||
      var keys = [];
 | 
					    for (const img of document.querySelectorAll("#small_pictures img")){
 | 
				
			||||||
      var pattern = "71,85,89,71,85,89";
 | 
					      img.addEventListener("click", (e) => {
 | 
				
			||||||
      $(document).keydown(function (e) {
 | 
					        const displayed = document.querySelector("#big_picture img");
 | 
				
			||||||
        keys.push(e.keyCode);
 | 
					        displayed.src = e.target.src;
 | 
				
			||||||
        if (keys.toString() == pattern) {
 | 
					        displayed.alt = e.target.alt;
 | 
				
			||||||
          keys = [];
 | 
					        displayed.title = e.target.title;
 | 
				
			||||||
          $("#big_picture img").attr("src", "{{ static('core/img/yug.jpg') }}");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        if (keys.length == 6) {
 | 
					 | 
				
			||||||
          keys.shift();
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    $(function () {
 | 
					 | 
				
			||||||
      $("#small_pictures img").click(function () {
 | 
					 | 
				
			||||||
        $("#big_picture img").attr("src", $(this)[0].src);
 | 
					 | 
				
			||||||
        $("#big_picture img").attr("alt", $(this)[0].alt);
 | 
					 | 
				
			||||||
        $("#big_picture img").attr("title", $(this)[0].title);
 | 
					 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let keys = [];
 | 
				
			||||||
 | 
					    const pattern = "71,85,89,71,85,89";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    document.addEventListener("keydown", (e) => {
 | 
				
			||||||
 | 
					      keys.push(e.keyCode);
 | 
				
			||||||
 | 
					      if (keys.toString() === pattern) {
 | 
				
			||||||
 | 
					        keys = [];
 | 
				
			||||||
 | 
					        document.querySelector("#big_picture img").src = "{{ static('core/img/yug.jpg') }}";
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (keys.length === 6) {
 | 
				
			||||||
 | 
					        keys.shift();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    $(function () {
 | 
					    $(function () {
 | 
				
			||||||
      $("#drop_gifts").accordion({
 | 
					      $("#drop_gifts").accordion({
 | 
				
			||||||
        heightStyle: "content",
 | 
					        heightStyle: "content",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@
 | 
				
			|||||||
    <h3>{% trans %}User Tools{% endtrans %}</h3>
 | 
					    <h3>{% trans %}User Tools{% endtrans %}</h3>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="container">
 | 
					    <div class="container">
 | 
				
			||||||
      {% if user.can_create_subscription or user.is_root or user.is_board_member %}
 | 
					      {% if user.has_perm("subscription.view_userban") or user.is_root or user.is_board_member %}
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
          <h4>{% trans %}Sith management{% endtrans %}</h4>
 | 
					          <h4>{% trans %}Sith management{% endtrans %}</h4>
 | 
				
			||||||
          <ul>
 | 
					          <ul>
 | 
				
			||||||
@@ -21,16 +21,16 @@
 | 
				
			|||||||
              <li><a href="{{ url('core:group_list') }}">{% trans %}Groups{% endtrans %}</a></li>
 | 
					              <li><a href="{{ url('core:group_list') }}">{% trans %}Groups{% endtrans %}</a></li>
 | 
				
			||||||
              <li><a href="{{ url('rootplace:merge') }}">{% trans %}Merge users{% endtrans %}</a></li>
 | 
					              <li><a href="{{ url('rootplace:merge') }}">{% trans %}Merge users{% endtrans %}</a></li>
 | 
				
			||||||
              <li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
 | 
					              <li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
 | 
				
			||||||
              <li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url('rootplace:delete_forum_messages') }}">
 | 
				
			||||||
 | 
					                  {% trans %}Delete user's forum messages{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
            {% endif %}
 | 
					            {% endif %}
 | 
				
			||||||
            {% if user.has_perm("core:view_userban") %}
 | 
					            {% if user.has_perm("core.view_userban") %}
 | 
				
			||||||
              <li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
 | 
					              <li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
 | 
				
			||||||
            {% endif %}
 | 
					            {% endif %}
 | 
				
			||||||
            {% if user.can_create_subscription or user.is_root %}
 | 
					 | 
				
			||||||
              <li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
            {% endif %}
 | 
					 | 
				
			||||||
            {% if user.is_board_member or user.is_root %}
 | 
					            {% if user.is_board_member or user.is_root %}
 | 
				
			||||||
              <li><a href="{{ url('subscription:stats') }}">{% trans %}Subscription stats{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
              <li><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></li>
 | 
					              <li><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></li>
 | 
				
			||||||
            {% endif %}
 | 
					            {% endif %}
 | 
				
			||||||
          </ul>
 | 
					          </ul>
 | 
				
			||||||
@@ -42,152 +42,202 @@
 | 
				
			|||||||
        {% set is_admin_on_a_counter = true %}
 | 
					        {% set is_admin_on_a_counter = true %}
 | 
				
			||||||
      {% endfor %}
 | 
					      {% endfor %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {% if
 | 
					      {% if is_admin_on_a_counter or user.is_root or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) %}
 | 
				
			||||||
      is_admin_on_a_counter
 | 
					        <div>
 | 
				
			||||||
      or user.is_root
 | 
					          <h4>{% trans %}Counters{% endtrans %}</h4>
 | 
				
			||||||
      or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
 | 
					          <ul>
 | 
				
			||||||
      %}
 | 
					            {% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) %}
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url('counter:admin_list') }}">
 | 
				
			||||||
 | 
					                  {% trans %}General counters management{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url('counter:product_list') }}">
 | 
				
			||||||
 | 
					                  {% trans %}Products management{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url('counter:product_type_list') }}">
 | 
				
			||||||
 | 
					                  {% trans %}Product types management{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url('counter:cash_summary_list') }}">
 | 
				
			||||||
 | 
					                  {% trans %}Cash register summaries{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url('counter:invoices_call') }}">
 | 
				
			||||||
 | 
					                  {% trans %}Invoices call{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url('counter:eticket_list') }}">
 | 
				
			||||||
 | 
					                  {% trans %}Etickets{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					          <ul>
 | 
				
			||||||
 | 
					            {% for b in settings.SITH_COUNTER_BARS %}
 | 
				
			||||||
 | 
					              {% if user.is_in_group(name=b[1]+" admin") %}
 | 
				
			||||||
 | 
					                {% set c = Counter.objects.filter(id=b[0]).first() %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <li class="rows counter">
 | 
				
			||||||
 | 
					                  <a href="{{ url('counter:details', counter_id=b[0]) }}">{{ b[1] }}</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  <span>
 | 
				
			||||||
 | 
					                    <span>
 | 
				
			||||||
 | 
					                      <a class="button" href="{{ url('counter:admin', counter_id=b[0]) }}">
 | 
				
			||||||
 | 
					                        {% trans %}Edit{% endtrans %}
 | 
				
			||||||
 | 
					                      </a>
 | 
				
			||||||
 | 
					                      <a class="button" href="{{ url('counter:stats', counter_id=b[0]) }}">
 | 
				
			||||||
 | 
					                        {% trans %}Stats{% endtrans %}
 | 
				
			||||||
 | 
					                      </a>
 | 
				
			||||||
 | 
					                    </span>
 | 
				
			||||||
 | 
					                  </span>
 | 
				
			||||||
 | 
					                </li>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					            {% endfor %}
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4>{% trans %}Accounting{% endtrans %}</h4>
 | 
				
			||||||
 | 
					          <ul>
 | 
				
			||||||
 | 
					            {% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
 | 
				
			||||||
 | 
					              <li><a href="{{ url('accounting:refound_account') }}">{% trans %}Refound Account{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('accounting:bank_list') }}">{% trans %}General accounting{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            {% for m in user.memberships.filter(end_date=None).filter(role__gte=7).all() -%}
 | 
				
			||||||
 | 
					              {%- for b in m.club.bank_accounts.all() %}
 | 
				
			||||||
 | 
					                <li class="rows">
 | 
				
			||||||
 | 
					                  <strong>{% trans %}Bank account: {% endtrans %}</strong>
 | 
				
			||||||
 | 
					                  <a href="{{ url('accounting:bank_details', b_account_id=b.id) }}">{{ b }}</a>
 | 
				
			||||||
 | 
					                </li>
 | 
				
			||||||
 | 
					              {%- endfor %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              {% if m.club.club_account.exists() -%}
 | 
				
			||||||
 | 
					                {% for ca in m.club.club_account.all() %}
 | 
				
			||||||
 | 
					                  <li class="rows">
 | 
				
			||||||
 | 
					                    <strong>{% trans %}Club account: {% endtrans %}</strong>
 | 
				
			||||||
 | 
					                    <a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca }}</a>
 | 
				
			||||||
 | 
					                  </li>
 | 
				
			||||||
 | 
					                {%- endfor %}
 | 
				
			||||||
 | 
					              {%- endif -%}
 | 
				
			||||||
 | 
					            {%- endfor %}
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {% if user.is_root or user.is_com_admin or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4>{% trans %}Communication{% endtrans %}</h4>
 | 
				
			||||||
 | 
					          <ul>
 | 
				
			||||||
 | 
					            {% if user.is_com_admin or user.is_root %}
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:weekmail_article') }}">{% trans %}Create weekmail article{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:weekmail') }}">{% trans %}Weekmail{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:weekmail_destinations') }}">{% trans %}Weekmail destinations{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:news_admin_list') }}">{% trans %}Moderate news{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					              <li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					            {% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
 | 
				
			||||||
 | 
					              <li><a href="{{ url('sas:moderation') }}">{% trans %}Moderate pictures{% endtrans %}</a></li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {% if user.has_perm("subscription.add_subscription") or user.has_perm("auth.change_perm") or user.is_root or user.is_board_member %}
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4>{% trans %}Subscriptions{% endtrans %}</h4>
 | 
				
			||||||
 | 
					          <ul>
 | 
				
			||||||
 | 
					            {% if user.has_perm("subscription.add_subscription") %}
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url("subscription:subscription") }}">
 | 
				
			||||||
 | 
					                  {% trans %}New subscription{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					            {% if user.has_perm("auth.change_permission") %}
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url("subscription:perms") }}">
 | 
				
			||||||
 | 
					                  {% trans %}Manage permissions{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					            {% if user.is_root or user.is_board_member %}
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url("subscription:stats") }}">
 | 
				
			||||||
 | 
					                  {% trans %}Subscription stats{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {% if user.memberships.filter(end_date=None).all().count() > 0 %}
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4>{% trans %}Club tools{% endtrans %}</h4>
 | 
				
			||||||
 | 
					          <ul>
 | 
				
			||||||
 | 
					            {% for m in user.memberships.filter(end_date=None).all() %}
 | 
				
			||||||
 | 
					              <li><a href="{{ url('club:tools', club_id=m.club.id) }}">{{ m.club }}</a></li>
 | 
				
			||||||
 | 
					            {% endfor %}
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {% if user.has_perm("pedagogy.add_uv") or user.has_perm("pedagogy.delete_uvcomment") %}
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4>{% trans %}Pedagogy{% endtrans %}</h4>
 | 
				
			||||||
 | 
					          <ul>
 | 
				
			||||||
 | 
					            {% if user.has_perm("pedagogy.add_uv") %}
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url("pedagogy:uv_create") }}">
 | 
				
			||||||
 | 
					                  {% trans %}Create UV{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					            {% if user.has_perm("pedagogy.delete_uvcomment") %}
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <a href="{{ url("pedagogy:moderation") }}">
 | 
				
			||||||
 | 
					                  {% trans %}Moderate comments{% endtrans %}
 | 
				
			||||||
 | 
					                </a>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <h4>{% trans %}Counters{% endtrans %}</h4>
 | 
					        <h4>{% trans %}Elections{% endtrans %}</h4>
 | 
				
			||||||
        <ul>
 | 
					        <ul>
 | 
				
			||||||
          {% if user.is_root
 | 
					          <li><a href="{{ url('election:list') }}">{% trans %}See available elections{% endtrans %}</a></li>
 | 
				
			||||||
          or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
 | 
					          <li><a href="{{ url('election:list_archived') }}">{% trans %}See archived elections{% endtrans %}</a></li>
 | 
				
			||||||
          %}
 | 
					          {%- if user.is_subscribed -%}
 | 
				
			||||||
          <li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
 | 
					            <li><a href="{{ url('election:create') }}">{% trans %}Create a new election{% endtrans %}</a></li>
 | 
				
			||||||
          <li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
 | 
					          {%- endif -%}
 | 
				
			||||||
          <li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
 | 
					        </ul>
 | 
				
			||||||
          <li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
 | 
					      </div>
 | 
				
			||||||
          <li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
          <li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
{% endif %}
 | 
					 | 
				
			||||||
</ul>
 | 
					 | 
				
			||||||
<ul>
 | 
					 | 
				
			||||||
  {% for b in settings.SITH_COUNTER_BARS %}
 | 
					 | 
				
			||||||
    {% if user.is_in_group(name=b[1]+" admin") %}
 | 
					 | 
				
			||||||
      {% set c = Counter.objects.filter(id=b[0]).first() %}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <li class="rows counter">
 | 
					      <div>
 | 
				
			||||||
        <a href="{{ url('counter:details', counter_id=b[0]) }}">{{ b[1] }}</a>
 | 
					        <h4>{% trans %}Other tools{% endtrans %}</h4>
 | 
				
			||||||
 | 
					        <ul>
 | 
				
			||||||
        <span>
 | 
					          <li><a href="{{ url('trombi:user_tools') }}">{% trans %}Trombi tools{% endtrans %}</a></li>
 | 
				
			||||||
          <span>
 | 
					        </ul>
 | 
				
			||||||
            <a class="button" href="{{ url('counter:admin', counter_id=b[0]) }}">{% trans %}Edit{% endtrans %}</a>
 | 
					      </div>
 | 
				
			||||||
            <a class="button" href="{{ url('counter:stats', counter_id=b[0]) }}">{% trans %}Stats{% endtrans %}</a>
 | 
					    </div>
 | 
				
			||||||
          </span>
 | 
					  </main>
 | 
				
			||||||
        </span>
 | 
					 | 
				
			||||||
      </li>
 | 
					 | 
				
			||||||
    {% endif %}
 | 
					 | 
				
			||||||
  {% endfor %}
 | 
					 | 
				
			||||||
</ul>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
{% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% if
 | 
					 | 
				
			||||||
user.is_root
 | 
					 | 
				
			||||||
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
 | 
					 | 
				
			||||||
or user.memberships.ongoing().filter(role__gte=7).count() > 10
 | 
					 | 
				
			||||||
%}
 | 
					 | 
				
			||||||
<div>
 | 
					 | 
				
			||||||
  <h4>{% trans %}Accounting{% endtrans %}</h4>
 | 
					 | 
				
			||||||
  <ul>
 | 
					 | 
				
			||||||
    {% if user.is_root
 | 
					 | 
				
			||||||
    or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
 | 
					 | 
				
			||||||
    %}
 | 
					 | 
				
			||||||
    <li><a href="{{ url('accounting:refound_account') }}">{% trans %}Refound Account{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    <li><a href="{{ url('accounting:bank_list') }}">{% trans %}General accounting{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    <li><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    {% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    {% for m in user.memberships.filter(end_date=None).filter(role__gte=7).all() -%}
 | 
					 | 
				
			||||||
      {%- for b in m.club.bank_accounts.all() %}
 | 
					 | 
				
			||||||
        <li class="rows">
 | 
					 | 
				
			||||||
          <strong>{% trans %}Bank account: {% endtrans %}</strong>
 | 
					 | 
				
			||||||
          <a href="{{ url('accounting:bank_details', b_account_id=b.id) }}">{{ b }}</a>
 | 
					 | 
				
			||||||
        </li>
 | 
					 | 
				
			||||||
      {%- endfor %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {% if m.club.club_account.exists() -%}
 | 
					 | 
				
			||||||
        {% for ca in m.club.club_account.all() %}
 | 
					 | 
				
			||||||
          <li class="rows">
 | 
					 | 
				
			||||||
            <strong>{% trans %}Club account: {% endtrans %}</strong>
 | 
					 | 
				
			||||||
            <a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca }}</a>
 | 
					 | 
				
			||||||
          </li>
 | 
					 | 
				
			||||||
        {%- endfor %}
 | 
					 | 
				
			||||||
      {%- endif -%}
 | 
					 | 
				
			||||||
    {%- endfor %}
 | 
					 | 
				
			||||||
  </ul>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
{% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% if
 | 
					 | 
				
			||||||
user.is_root
 | 
					 | 
				
			||||||
or user.is_com_admin
 | 
					 | 
				
			||||||
or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
 | 
					 | 
				
			||||||
%}
 | 
					 | 
				
			||||||
<div>
 | 
					 | 
				
			||||||
  <h4>{% trans %}Communication{% endtrans %}</h4>
 | 
					 | 
				
			||||||
  <ul>
 | 
					 | 
				
			||||||
    {% if user.is_com_admin or user.is_root %}
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:weekmail_article') }}">{% trans %}Create weekmail article{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:weekmail') }}">{% trans %}Weekmail{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:weekmail_destinations') }}">{% trans %}Weekmail destinations{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:news_admin_list') }}">{% trans %}Moderate news{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
      <li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    {% endif %}
 | 
					 | 
				
			||||||
    {% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
 | 
					 | 
				
			||||||
      <li><a href="{{ url('sas:moderation') }}">{% trans %}Moderate pictures{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    {% endif %}
 | 
					 | 
				
			||||||
  </ul>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
{% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% if user.memberships.filter(end_date=None).all().count() > 0 %}
 | 
					 | 
				
			||||||
  <div>
 | 
					 | 
				
			||||||
    <h4>{% trans %}Club tools{% endtrans %}</h4>
 | 
					 | 
				
			||||||
    <ul>
 | 
					 | 
				
			||||||
      {% for m in user.memberships.filter(end_date=None).all() %}
 | 
					 | 
				
			||||||
        <li><a href="{{ url('club:tools', club_id=m.club.id) }}">{{ m.club }}</a></li>
 | 
					 | 
				
			||||||
      {% endfor %}
 | 
					 | 
				
			||||||
    </ul>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
{% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% if
 | 
					 | 
				
			||||||
user.is_root
 | 
					 | 
				
			||||||
or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
 | 
					 | 
				
			||||||
%}
 | 
					 | 
				
			||||||
<div>
 | 
					 | 
				
			||||||
  <h4>{% trans %}Pedagogy{% endtrans %}</h4>
 | 
					 | 
				
			||||||
  <ul>
 | 
					 | 
				
			||||||
    <li><a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    <li><a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
  </ul>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
{% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<div>
 | 
					 | 
				
			||||||
  <h4>{% trans %}Elections{% endtrans %}</h4>
 | 
					 | 
				
			||||||
  <ul>
 | 
					 | 
				
			||||||
    <li><a href="{{ url('election:list') }}">{% trans %}See available elections{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    <li><a href="{{ url('election:list_archived') }}">{% trans %}See archived elections{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    {%- if user.is_subscribed -%}
 | 
					 | 
				
			||||||
      <li><a href="{{ url('election:create') }}">{% trans %}Create a new election{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
    {%- endif -%}
 | 
					 | 
				
			||||||
  </ul>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<div>
 | 
					 | 
				
			||||||
  <h4>{% trans %}Other tools{% endtrans %}</h4>
 | 
					 | 
				
			||||||
  <ul>
 | 
					 | 
				
			||||||
    <li><a href="{{ url('trombi:user_tools') }}">{% trans %}Trombi tools{% endtrans %}</a></li>
 | 
					 | 
				
			||||||
  </ul>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
</main>
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
@@ -26,6 +26,7 @@ import datetime
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import phonenumbers
 | 
					import phonenumbers
 | 
				
			||||||
from django import template
 | 
					from django import template
 | 
				
			||||||
 | 
					from django.forms import BoundField
 | 
				
			||||||
from django.template.defaultfilters import stringfilter
 | 
					from django.template.defaultfilters import stringfilter
 | 
				
			||||||
from django.utils.safestring import mark_safe
 | 
					from django.utils.safestring import mark_safe
 | 
				
			||||||
from django.utils.translation import ngettext
 | 
					from django.utils.translation import ngettext
 | 
				
			||||||
@@ -80,3 +81,43 @@ def format_timedelta(value: datetime.timedelta) -> str:
 | 
				
			|||||||
    return ngettext(
 | 
					    return ngettext(
 | 
				
			||||||
        "%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
 | 
					        "%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
 | 
				
			||||||
    ) % {"nb_days": days, "remainder": str(remainder)}
 | 
					    ) % {"nb_days": days, "remainder": str(remainder)}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@register.filter(name="add_attr")
 | 
				
			||||||
 | 
					def add_attr(field: BoundField, attr: str):
 | 
				
			||||||
 | 
					    """Add attributes to a form field directly in the template.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Attributes are `key=value` pairs, separated by commas.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Example:
 | 
				
			||||||
 | 
					        ```jinja
 | 
				
			||||||
 | 
					        <form x-data="{alpineField: null}">
 | 
				
			||||||
 | 
					            {{ form.field|add_attr("x-model=alpineField") }}
 | 
				
			||||||
 | 
					        </form>
 | 
				
			||||||
 | 
					        ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        will render :
 | 
				
			||||||
 | 
					        ```html
 | 
				
			||||||
 | 
					        <form x-data="{alpineField: null}">
 | 
				
			||||||
 | 
					            <input type="..." x-model="alpineField">
 | 
				
			||||||
 | 
					        </form>
 | 
				
			||||||
 | 
					        ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Notes:
 | 
				
			||||||
 | 
					        Doing this gives the same result as setting the attribute
 | 
				
			||||||
 | 
					        directly in the python code.
 | 
				
			||||||
 | 
					        However, sometimes there are attributes that are tightly
 | 
				
			||||||
 | 
					        coupled to the frontend logic (like Alpine variables)
 | 
				
			||||||
 | 
					        and that shouldn't be declared outside of it.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    attrs = {}
 | 
				
			||||||
 | 
					    definition = attr.split(",")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for d in definition:
 | 
				
			||||||
 | 
					        if "=" not in d:
 | 
				
			||||||
 | 
					            attrs["class"] = d
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            key, val = d.split("=")
 | 
				
			||||||
 | 
					            attrs[key] = val
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return field.as_widget(attrs=attrs)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -327,12 +327,9 @@ http://git.an
 | 
				
			|||||||
class TestUserTools:
 | 
					class TestUserTools:
 | 
				
			||||||
    def test_anonymous_user_unauthorized(self, client):
 | 
					    def test_anonymous_user_unauthorized(self, client):
 | 
				
			||||||
        """An anonymous user shouldn't have access to the tools page."""
 | 
					        """An anonymous user shouldn't have access to the tools page."""
 | 
				
			||||||
        response = client.get(reverse("core:user_tools"))
 | 
					        url = reverse("core:user_tools")
 | 
				
			||||||
        assertRedirects(
 | 
					        response = client.get(url)
 | 
				
			||||||
            response,
 | 
					        assertRedirects(response, expected_url=reverse("core:login") + f"?next={url}")
 | 
				
			||||||
            expected_url="/login?next=%2Fuser%2Ftools%2F",
 | 
					 | 
				
			||||||
            target_status_code=301,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"])
 | 
					    @pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"])
 | 
				
			||||||
    def test_page_is_working(self, client, username):
 | 
					    def test_page_is_working(self, client, username):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -64,40 +64,6 @@ class TestImageAccess:
 | 
				
			|||||||
        assert not picture.is_owned_by(user)
 | 
					        assert not picture.is_owned_by(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@pytest.mark.django_db
 | 
					 | 
				
			||||||
class TestUserPicture:
 | 
					 | 
				
			||||||
    def test_anonymous_user_unauthorized(self, client):
 | 
					 | 
				
			||||||
        """An anonymous user shouldn't have access to an user's photo page."""
 | 
					 | 
				
			||||||
        response = client.get(
 | 
					 | 
				
			||||||
            reverse(
 | 
					 | 
				
			||||||
                "core:user_pictures",
 | 
					 | 
				
			||||||
                kwargs={"user_id": User.objects.get(username="sli").pk},
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        assert response.status_code == 403
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @pytest.mark.parametrize(
 | 
					 | 
				
			||||||
        ("username", "status"),
 | 
					 | 
				
			||||||
        [
 | 
					 | 
				
			||||||
            ("guy", 403),
 | 
					 | 
				
			||||||
            ("root", 200),
 | 
					 | 
				
			||||||
            ("skia", 200),
 | 
					 | 
				
			||||||
            ("sli", 200),
 | 
					 | 
				
			||||||
        ],
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    def test_page_is_working(self, client, username, status):
 | 
					 | 
				
			||||||
        """Only user that subscribed (or admins) should be able to see the page."""
 | 
					 | 
				
			||||||
        # Test for simple user
 | 
					 | 
				
			||||||
        client.force_login(User.objects.get(username=username))
 | 
					 | 
				
			||||||
        response = client.get(
 | 
					 | 
				
			||||||
            reverse(
 | 
					 | 
				
			||||||
                "core:user_pictures",
 | 
					 | 
				
			||||||
                kwargs={"user_id": User.objects.get(username="sli").pk},
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        assert response.status_code == status
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# TODO: many tests on the pages:
 | 
					# TODO: many tests on the pages:
 | 
				
			||||||
#   - renaming a page
 | 
					#   - renaming a page
 | 
				
			||||||
#   - changing a page's parent --> check that page's children's full_name
 | 
					#   - changing a page's parent --> check that page's children's full_name
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,13 +8,15 @@ from django.urls import reverse
 | 
				
			|||||||
from django.utils.timezone import now
 | 
					from django.utils.timezone import now
 | 
				
			||||||
from model_bakery import baker, seq
 | 
					from model_bakery import baker, seq
 | 
				
			||||||
from model_bakery.recipe import Recipe, foreign_key
 | 
					from model_bakery.recipe import Recipe, foreign_key
 | 
				
			||||||
 | 
					from pytest_django.asserts import assertRedirects
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from com.models import News
 | 
				
			||||||
from core.baker_recipes import (
 | 
					from core.baker_recipes import (
 | 
				
			||||||
    old_subscriber_user,
 | 
					    old_subscriber_user,
 | 
				
			||||||
    subscriber_user,
 | 
					    subscriber_user,
 | 
				
			||||||
    very_old_subscriber_user,
 | 
					    very_old_subscriber_user,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from core.models import User
 | 
					from core.models import Group, User
 | 
				
			||||||
from counter.models import Counter, Refilling, Selling
 | 
					from counter.models import Counter, Refilling, Selling
 | 
				
			||||||
from eboutic.models import Invoice, InvoiceItem
 | 
					from eboutic.models import Invoice, InvoiceItem
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,6 +24,8 @@ from eboutic.models import Invoice, InvoiceItem
 | 
				
			|||||||
class TestSearchUsers(TestCase):
 | 
					class TestSearchUsers(TestCase):
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
    def setUpTestData(cls):
 | 
					    def setUpTestData(cls):
 | 
				
			||||||
 | 
					        # News.author has on_delete=PROTECT, so news must be deleted beforehand
 | 
				
			||||||
 | 
					        News.objects.all().delete()
 | 
				
			||||||
        User.objects.all().delete()
 | 
					        User.objects.all().delete()
 | 
				
			||||||
        user_recipe = Recipe(
 | 
					        user_recipe = Recipe(
 | 
				
			||||||
            User,
 | 
					            User,
 | 
				
			||||||
@@ -187,3 +191,31 @@ def test_generate_username(first_name: str, last_name: str, expected: str):
 | 
				
			|||||||
    new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
 | 
					    new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
 | 
				
			||||||
    new_user.generate_username()
 | 
					    new_user.generate_username()
 | 
				
			||||||
    assert new_user.username == expected
 | 
					    assert new_user.username == expected
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.django_db
 | 
				
			||||||
 | 
					def test_user_added_to_public_group():
 | 
				
			||||||
 | 
					    """Test that newly created users are added to the public group"""
 | 
				
			||||||
 | 
					    user = baker.make(User)
 | 
				
			||||||
 | 
					    assert user.groups.filter(pk=settings.SITH_GROUP_PUBLIC_ID).exists()
 | 
				
			||||||
 | 
					    assert user.is_in_group(pk=settings.SITH_GROUP_PUBLIC_ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.django_db
 | 
				
			||||||
 | 
					def test_user_update_groups(client: Client):
 | 
				
			||||||
 | 
					    client.force_login(baker.make(User, is_superuser=True))
 | 
				
			||||||
 | 
					    manageable_groups = baker.make(Group, is_manually_manageable=True, _quantity=3)
 | 
				
			||||||
 | 
					    hidden_groups = baker.make(Group, is_manually_manageable=False, _quantity=4)
 | 
				
			||||||
 | 
					    user = baker.make(User, groups=[*manageable_groups[1:], *hidden_groups[:3]])
 | 
				
			||||||
 | 
					    response = client.post(
 | 
				
			||||||
 | 
					        reverse("core:user_groups", kwargs={"user_id": user.id}),
 | 
				
			||||||
 | 
					        data={"groups": [manageable_groups[0].id, manageable_groups[1].id]},
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    assertRedirects(response, user.get_absolute_url())
 | 
				
			||||||
 | 
					    # only the manually manageable groups should have changed
 | 
				
			||||||
 | 
					    assert set(user.groups.all()) == {
 | 
				
			||||||
 | 
					        Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID),
 | 
				
			||||||
 | 
					        manageable_groups[0],
 | 
				
			||||||
 | 
					        manageable_groups[1],
 | 
				
			||||||
 | 
					        *hidden_groups[:3],
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,7 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.urls import path, re_path, register_converter
 | 
					from django.urls import path, re_path, register_converter
 | 
				
			||||||
 | 
					from django.views.generic import RedirectView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.converters import (
 | 
					from core.converters import (
 | 
				
			||||||
    BooleanStringConverter,
 | 
					    BooleanStringConverter,
 | 
				
			||||||
@@ -68,7 +69,6 @@ from core.views import (
 | 
				
			|||||||
    UserGodfathersView,
 | 
					    UserGodfathersView,
 | 
				
			||||||
    UserListView,
 | 
					    UserListView,
 | 
				
			||||||
    UserMiniView,
 | 
					    UserMiniView,
 | 
				
			||||||
    UserPicturesView,
 | 
					 | 
				
			||||||
    UserPreferencesView,
 | 
					    UserPreferencesView,
 | 
				
			||||||
    UserStatsView,
 | 
					    UserStatsView,
 | 
				
			||||||
    UserToolsView,
 | 
					    UserToolsView,
 | 
				
			||||||
@@ -144,7 +144,8 @@ urlpatterns = [
 | 
				
			|||||||
    path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
 | 
					    path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
 | 
				
			||||||
    path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
 | 
					    path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
        "user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
 | 
					        "user/<int:user_id>/pictures/",
 | 
				
			||||||
 | 
					        RedirectView.as_view(pattern_name="sas:user_pictures", permanent=True),
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
        "user/<int:user_id>/godfathers/",
 | 
					        "user/<int:user_id>/godfathers/",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from dataclasses import dataclass
 | 
					from dataclasses import dataclass
 | 
				
			||||||
from datetime import date
 | 
					from datetime import date, timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Image utils
 | 
					# Image utils
 | 
				
			||||||
from io import BytesIO
 | 
					from io import BytesIO
 | 
				
			||||||
@@ -77,6 +77,22 @@ def get_start_of_semester(today: date | None = None) -> date:
 | 
				
			|||||||
    return autumn.replace(year=autumn.year - 1)
 | 
					    return autumn.replace(year=autumn.year - 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_end_of_semester(today: date | None = None):
 | 
				
			||||||
 | 
					    """Return the date of the end of the semester of the given date.
 | 
				
			||||||
 | 
					    If no date is given, return the end date of the current semester.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    # the algorithm is simple, albeit somewhat imprecise :
 | 
				
			||||||
 | 
					    # 1. get the start of the next semester
 | 
				
			||||||
 | 
					    # 2. Remove a month and a half for the autumn semester (summer holidays)
 | 
				
			||||||
 | 
					    #    and 28 days for spring semester (february holidays)
 | 
				
			||||||
 | 
					    if today is None:
 | 
				
			||||||
 | 
					        today = localdate()
 | 
				
			||||||
 | 
					    semester_start = get_start_of_semester(today + timedelta(days=365 // 2))
 | 
				
			||||||
 | 
					    if semester_start.month == settings.SITH_SEMESTER_START_AUTUMN[0]:
 | 
				
			||||||
 | 
					        return semester_start - timedelta(days=45)
 | 
				
			||||||
 | 
					    return semester_start - timedelta(days=28)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_semester_code(d: date | None = None) -> str:
 | 
					def get_semester_code(d: date | None = None) -> str:
 | 
				
			||||||
    """Return the semester code of the given date.
 | 
					    """Return the semester code of the given date.
 | 
				
			||||||
    If no date is given, return the semester code of the current semester.
 | 
					    If no date is given, return the semester code of the current semester.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,28 +22,16 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import types
 | 
					 | 
				
			||||||
from typing import Any
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
from django.contrib.auth.mixins import AccessMixin
 | 
					 | 
				
			||||||
from django.core.exceptions import (
 | 
					 | 
				
			||||||
    ImproperlyConfigured,
 | 
					 | 
				
			||||||
    PermissionDenied,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
from django.http import (
 | 
					from django.http import (
 | 
				
			||||||
    HttpResponseForbidden,
 | 
					    HttpResponseForbidden,
 | 
				
			||||||
    HttpResponseNotFound,
 | 
					    HttpResponseNotFound,
 | 
				
			||||||
    HttpResponseServerError,
 | 
					    HttpResponseServerError,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from django.shortcuts import render
 | 
					from django.shortcuts import render
 | 
				
			||||||
from django.utils.functional import cached_property
 | 
					from django.views.generic.detail import BaseDetailView
 | 
				
			||||||
from django.views.generic.base import View
 | 
					 | 
				
			||||||
from django.views.generic.detail import SingleObjectMixin
 | 
					 | 
				
			||||||
from django.views.generic.edit import FormView
 | 
					from django.views.generic.edit import FormView
 | 
				
			||||||
from sentry_sdk import last_event_id
 | 
					from sentry_sdk import last_event_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.models import User
 | 
					 | 
				
			||||||
from core.views.forms import LoginForm
 | 
					from core.views.forms import LoginForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -65,273 +53,12 @@ def internal_servor_error(request):
 | 
				
			|||||||
    return HttpResponseServerError(render(request, "core/500.jinja"))
 | 
					    return HttpResponseServerError(render(request, "core/500.jinja"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def can_edit_prop(obj: Any, user: User) -> bool:
 | 
					class DetailFormView(FormView, BaseDetailView):
 | 
				
			||||||
    """Can the user edit the properties of the object.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Args:
 | 
					 | 
				
			||||||
        obj: Object to test for permission
 | 
					 | 
				
			||||||
        user: core.models.User to test permissions against
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Returns:
 | 
					 | 
				
			||||||
        True if user is authorized to edit object properties else False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Examples:
 | 
					 | 
				
			||||||
        ```python
 | 
					 | 
				
			||||||
        if not can_edit_prop(self.object ,request.user):
 | 
					 | 
				
			||||||
            raise PermissionDenied
 | 
					 | 
				
			||||||
        ```
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    return obj is None or user.is_owner(obj)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def can_edit(obj: Any, user: User) -> bool:
 | 
					 | 
				
			||||||
    """Can the user edit the object.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Args:
 | 
					 | 
				
			||||||
        obj: Object to test for permission
 | 
					 | 
				
			||||||
        user: core.models.User to test permissions against
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Returns:
 | 
					 | 
				
			||||||
        True if user is authorized to edit object else False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Examples:
 | 
					 | 
				
			||||||
        ```python
 | 
					 | 
				
			||||||
        if not can_edit(self.object, request.user):
 | 
					 | 
				
			||||||
            raise PermissionDenied
 | 
					 | 
				
			||||||
        ```
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    if obj is None or user.can_edit(obj):
 | 
					 | 
				
			||||||
        return True
 | 
					 | 
				
			||||||
    return can_edit_prop(obj, user)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def can_view(obj: Any, user: User) -> bool:
 | 
					 | 
				
			||||||
    """Can the user see the object.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Args:
 | 
					 | 
				
			||||||
        obj: Object to test for permission
 | 
					 | 
				
			||||||
        user: core.models.User to test permissions against
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Returns:
 | 
					 | 
				
			||||||
        True if user is authorized to see object else False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Examples:
 | 
					 | 
				
			||||||
        ```python
 | 
					 | 
				
			||||||
        if not can_view(self.object ,request.user):
 | 
					 | 
				
			||||||
            raise PermissionDenied
 | 
					 | 
				
			||||||
        ```
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    if obj is None or user.can_view(obj):
 | 
					 | 
				
			||||||
        return True
 | 
					 | 
				
			||||||
    return can_edit(obj, user)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class GenericContentPermissionMixinBuilder(View):
 | 
					 | 
				
			||||||
    """Used to build permission mixins.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    This view protect any child view that would be showing an object that is restricted based
 | 
					 | 
				
			||||||
      on two properties.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Attributes:
 | 
					 | 
				
			||||||
        raised_error: permission to be raised
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    raised_error = PermissionDenied
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @staticmethod
 | 
					 | 
				
			||||||
    def permission_function(obj: Any, user: User) -> bool:
 | 
					 | 
				
			||||||
        """Function to test permission with."""
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @classmethod
 | 
					 | 
				
			||||||
    def get_permission_function(cls, obj, user):
 | 
					 | 
				
			||||||
        return cls.permission_function(obj, user)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def dispatch(self, request, *arg, **kwargs):
 | 
					 | 
				
			||||||
        if hasattr(self, "get_object") and callable(self.get_object):
 | 
					 | 
				
			||||||
            self.object = self.get_object()
 | 
					 | 
				
			||||||
            if not self.get_permission_function(self.object, request.user):
 | 
					 | 
				
			||||||
                raise self.raised_error
 | 
					 | 
				
			||||||
            return super().dispatch(request, *arg, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # If we get here, it's a ListView
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        queryset = self.get_queryset()
 | 
					 | 
				
			||||||
        l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
 | 
					 | 
				
			||||||
        if not l_id and queryset.count() != 0:
 | 
					 | 
				
			||||||
            raise self.raised_error
 | 
					 | 
				
			||||||
        self._get_queryset = self.get_queryset
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        def get_qs(self2):
 | 
					 | 
				
			||||||
            return self2._get_queryset().filter(id__in=l_id)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.get_queryset = types.MethodType(get_qs, self)
 | 
					 | 
				
			||||||
        return super().dispatch(request, *arg, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class CanCreateMixin(View):
 | 
					 | 
				
			||||||
    """Protect any child view that would create an object.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Raises:
 | 
					 | 
				
			||||||
        PermissionDenied:
 | 
					 | 
				
			||||||
            If the user has not the necessary permission
 | 
					 | 
				
			||||||
            to create the object of the view.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def dispatch(self, request, *arg, **kwargs):
 | 
					 | 
				
			||||||
        res = super().dispatch(request, *arg, **kwargs)
 | 
					 | 
				
			||||||
        if not request.user.is_authenticated:
 | 
					 | 
				
			||||||
            raise PermissionDenied
 | 
					 | 
				
			||||||
        return res
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def form_valid(self, form):
 | 
					 | 
				
			||||||
        obj = form.instance
 | 
					 | 
				
			||||||
        if can_edit_prop(obj, self.request.user):
 | 
					 | 
				
			||||||
            return super().form_valid(form)
 | 
					 | 
				
			||||||
        raise PermissionDenied
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
 | 
					 | 
				
			||||||
    """Ensure the user has owner permissions on the child view object.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    In other word, you can make a view with this view as parent,
 | 
					 | 
				
			||||||
    and it will be retricted to the users that are in the
 | 
					 | 
				
			||||||
    object's owner_group or that pass the `obj.can_be_viewed_by` test.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Raises:
 | 
					 | 
				
			||||||
        PermissionDenied: If the user cannot see the object
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    permission_function = can_edit_prop
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class CanEditMixin(GenericContentPermissionMixinBuilder):
 | 
					 | 
				
			||||||
    """Ensure the user has permission to edit this view's object.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Raises:
 | 
					 | 
				
			||||||
        PermissionDenied: if the user cannot edit this view's object.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    permission_function = can_edit
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class CanViewMixin(GenericContentPermissionMixinBuilder):
 | 
					 | 
				
			||||||
    """Ensure the user has permission to view this view's object.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Raises:
 | 
					 | 
				
			||||||
        PermissionDenied: if the user cannot edit this view's object.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    permission_function = can_view
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class UserIsRootMixin(GenericContentPermissionMixinBuilder):
 | 
					 | 
				
			||||||
    """Allow only root admins.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Raises:
 | 
					 | 
				
			||||||
        PermissionDenied: if the user isn't root
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @staticmethod
 | 
					 | 
				
			||||||
    def permission_function(obj: Any, user: User):
 | 
					 | 
				
			||||||
        return user.is_root
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FormerSubscriberMixin(AccessMixin):
 | 
					 | 
				
			||||||
    """Check if the user was at least an old subscriber.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Raises:
 | 
					 | 
				
			||||||
        PermissionDenied: if the user never subscribed.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def dispatch(self, request, *args, **kwargs):
 | 
					 | 
				
			||||||
        if not request.user.was_subscribed:
 | 
					 | 
				
			||||||
            raise PermissionDenied
 | 
					 | 
				
			||||||
        return super().dispatch(request, *args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SubscriberMixin(AccessMixin):
 | 
					 | 
				
			||||||
    def dispatch(self, request, *args, **kwargs):
 | 
					 | 
				
			||||||
        if not request.user.is_subscribed:
 | 
					 | 
				
			||||||
            return self.handle_no_permission()
 | 
					 | 
				
			||||||
        return super().dispatch(request, *args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TabedViewMixin(View):
 | 
					 | 
				
			||||||
    """Basic functions for displaying tabs in the template."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_tabs_title(self):
 | 
					 | 
				
			||||||
        if hasattr(self, "tabs_title"):
 | 
					 | 
				
			||||||
            return self.tabs_title
 | 
					 | 
				
			||||||
        raise ImproperlyConfigured("tabs_title is required")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_current_tab(self):
 | 
					 | 
				
			||||||
        if hasattr(self, "current_tab"):
 | 
					 | 
				
			||||||
            return self.current_tab
 | 
					 | 
				
			||||||
        raise ImproperlyConfigured("current_tab is required")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_list_of_tabs(self):
 | 
					 | 
				
			||||||
        if hasattr(self, "list_of_tabs"):
 | 
					 | 
				
			||||||
            return self.list_of_tabs
 | 
					 | 
				
			||||||
        raise ImproperlyConfigured("list_of_tabs is required")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_context_data(self, **kwargs):
 | 
					 | 
				
			||||||
        kwargs = super().get_context_data(**kwargs)
 | 
					 | 
				
			||||||
        kwargs["tabs_title"] = self.get_tabs_title()
 | 
					 | 
				
			||||||
        kwargs["current_tab"] = self.get_current_tab()
 | 
					 | 
				
			||||||
        kwargs["list_of_tabs"] = self.get_list_of_tabs()
 | 
					 | 
				
			||||||
        return kwargs
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class QuickNotifMixin:
 | 
					 | 
				
			||||||
    quick_notif_list = []
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def dispatch(self, request, *arg, **kwargs):
 | 
					 | 
				
			||||||
        # In some cases, the class can stay instanciated, so we need to reset the list
 | 
					 | 
				
			||||||
        self.quick_notif_list = []
 | 
					 | 
				
			||||||
        return super().dispatch(request, *arg, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_success_url(self):
 | 
					 | 
				
			||||||
        ret = super().get_success_url()
 | 
					 | 
				
			||||||
        if hasattr(self, "quick_notif_url_arg"):
 | 
					 | 
				
			||||||
            if "?" in ret:
 | 
					 | 
				
			||||||
                ret += "&" + self.quick_notif_url_arg
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                ret += "?" + self.quick_notif_url_arg
 | 
					 | 
				
			||||||
        return ret
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_context_data(self, **kwargs):
 | 
					 | 
				
			||||||
        """Add quick notifications to context."""
 | 
					 | 
				
			||||||
        kwargs = super().get_context_data(**kwargs)
 | 
					 | 
				
			||||||
        kwargs["quick_notifs"] = []
 | 
					 | 
				
			||||||
        for n in self.quick_notif_list:
 | 
					 | 
				
			||||||
            kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
 | 
					 | 
				
			||||||
        for key, val in settings.SITH_QUICK_NOTIF.items():
 | 
					 | 
				
			||||||
            for gk in self.request.GET:
 | 
					 | 
				
			||||||
                if key == gk:
 | 
					 | 
				
			||||||
                    kwargs["quick_notifs"].append(val)
 | 
					 | 
				
			||||||
        return kwargs
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class DetailFormView(SingleObjectMixin, FormView):
 | 
					 | 
				
			||||||
    """Class that allow both a detail view and a form view."""
 | 
					    """Class that allow both a detail view and a form view."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_object(self):
 | 
					    def post(self, request, *args, **kwargs):
 | 
				
			||||||
        """Get current group from id in url."""
 | 
					        self.object = self.get_object()
 | 
				
			||||||
        return self.cached_object
 | 
					        return super().post(request, *args, **kwargs)
 | 
				
			||||||
 | 
					 | 
				
			||||||
    @cached_property
 | 
					 | 
				
			||||||
    def cached_object(self):
 | 
					 | 
				
			||||||
        """Optimisation on group retrieval."""
 | 
					 | 
				
			||||||
        return super().get_object()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class AllowFragment:
 | 
					 | 
				
			||||||
    """Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_context_data(self, **kwargs):
 | 
					 | 
				
			||||||
        kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
 | 
					 | 
				
			||||||
        return super().get_context_data(**kwargs)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# F403: those star-imports would be hellish to refactor
 | 
					# F403: those star-imports would be hellish to refactor
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,14 +33,14 @@ from django.views.generic import DetailView, ListView
 | 
				
			|||||||
from django.views.generic.detail import SingleObjectMixin
 | 
					from django.views.generic.detail import SingleObjectMixin
 | 
				
			||||||
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
 | 
					from django.views.generic.edit import DeleteView, FormMixin, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.models import Notification, SithFile, User
 | 
					from core.auth.mixins import (
 | 
				
			||||||
from core.views import (
 | 
					 | 
				
			||||||
    AllowFragment,
 | 
					 | 
				
			||||||
    CanEditMixin,
 | 
					    CanEditMixin,
 | 
				
			||||||
    CanEditPropMixin,
 | 
					    CanEditPropMixin,
 | 
				
			||||||
    CanViewMixin,
 | 
					    CanViewMixin,
 | 
				
			||||||
    can_view,
 | 
					    can_view,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from core.models import Notification, SithFile, User
 | 
				
			||||||
 | 
					from core.views.mixins import AllowFragment
 | 
				
			||||||
from core.views.widgets.select import (
 | 
					from core.views.widgets.select import (
 | 
				
			||||||
    AutoCompleteSelectMultipleGroup,
 | 
					    AutoCompleteSelectMultipleGroup,
 | 
				
			||||||
    AutoCompleteSelectSithFile,
 | 
					    AutoCompleteSelectSithFile,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,6 +28,7 @@ from captcha.fields import CaptchaField
 | 
				
			|||||||
from django import forms
 | 
					from django import forms
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
 | 
					from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
 | 
				
			||||||
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
from django.contrib.staticfiles.management.commands.collectstatic import (
 | 
					from django.contrib.staticfiles.management.commands.collectstatic import (
 | 
				
			||||||
    staticfiles_storage,
 | 
					    staticfiles_storage,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -323,6 +324,19 @@ class UserGroupsForm(forms.ModelForm):
 | 
				
			|||||||
        model = User
 | 
					        model = User
 | 
				
			||||||
        fields = ["groups"]
 | 
					        fields = ["groups"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, *args, **kwargs) -> User:
 | 
				
			||||||
 | 
					        # make the super method manage error without persisting in db
 | 
				
			||||||
 | 
					        super().save(commit=False)
 | 
				
			||||||
 | 
					        # Don't forget to add the non-manageable groups when setting groups,
 | 
				
			||||||
 | 
					        # or the user would lose all of those when the form is submitted
 | 
				
			||||||
 | 
					        self.instance.groups.set(
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                *self.cleaned_data["groups"],
 | 
				
			||||||
 | 
					                *self.instance.groups.filter(is_manually_manageable=False),
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return self.instance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserGodfathersForm(forms.Form):
 | 
					class UserGodfathersForm(forms.Form):
 | 
				
			||||||
    type = forms.ChoiceField(
 | 
					    type = forms.ChoiceField(
 | 
				
			||||||
@@ -427,3 +441,28 @@ class GiftForm(forms.ModelForm):
 | 
				
			|||||||
                id=user_id
 | 
					                id=user_id
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            self.fields["user"].widget = forms.HiddenInput()
 | 
					            self.fields["user"].widget = forms.HiddenInput()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PermissionGroupsForm(forms.ModelForm):
 | 
				
			||||||
 | 
					    """Manage the groups that have a specific permission."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = Permission
 | 
				
			||||||
 | 
					        fields = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    groups = forms.ModelMultipleChoiceField(
 | 
				
			||||||
 | 
					        Group.objects.all(),
 | 
				
			||||||
 | 
					        label=_("Groups"),
 | 
				
			||||||
 | 
					        widget=AutoCompleteSelectMultipleGroup,
 | 
				
			||||||
 | 
					        required=False,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, instance: Permission, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(instance=instance, **kwargs)
 | 
				
			||||||
 | 
					        self.fields["groups"].initial = instance.group_set.all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, commit: bool = True):  # noqa FTB001
 | 
				
			||||||
 | 
					        instance = super().save(commit=False)
 | 
				
			||||||
 | 
					        if commit:
 | 
				
			||||||
 | 
					            instance.group_set.set(self.cleaned_data["groups"])
 | 
				
			||||||
 | 
					        return instance
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,13 +16,20 @@
 | 
				
			|||||||
"""Views to manage Groups."""
 | 
					"""Views to manage Groups."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django import forms
 | 
					from django import forms
 | 
				
			||||||
 | 
					from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
				
			||||||
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
 | 
					from django.contrib.messages.views import SuccessMessageMixin
 | 
				
			||||||
 | 
					from django.core.exceptions import ImproperlyConfigured
 | 
				
			||||||
 | 
					from django.shortcuts import get_object_or_404
 | 
				
			||||||
from django.urls import reverse_lazy
 | 
					from django.urls import reverse_lazy
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from django.views.generic import ListView
 | 
					from django.views.generic import ListView
 | 
				
			||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
					from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.auth.mixins import CanEditMixin
 | 
				
			||||||
from core.models import Group, User
 | 
					from core.models import Group, User
 | 
				
			||||||
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
 | 
					from core.views import DetailFormView
 | 
				
			||||||
 | 
					from core.views.forms import PermissionGroupsForm
 | 
				
			||||||
from core.views.widgets.select import AutoCompleteSelectMultipleUser
 | 
					from core.views.widgets.select import AutoCompleteSelectMultipleUser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Forms
 | 
					# Forms
 | 
				
			||||||
@@ -73,13 +80,14 @@ class GroupEditView(CanEditMixin, UpdateView):
 | 
				
			|||||||
    fields = ["name", "description"]
 | 
					    fields = ["name", "description"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class GroupCreateView(CanCreateMixin, CreateView):
 | 
					class GroupCreateView(PermissionRequiredMixin, CreateView):
 | 
				
			||||||
    """Add a new Group."""
 | 
					    """Add a new Group."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Group
 | 
					    model = Group
 | 
				
			||||||
    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
					    queryset = Group.objects.filter(is_manually_manageable=True)
 | 
				
			||||||
    template_name = "core/create.jinja"
 | 
					    template_name = "core/create.jinja"
 | 
				
			||||||
    fields = ["name", "description"]
 | 
					    fields = ["name", "description"]
 | 
				
			||||||
 | 
					    permission_required = "core.add_group"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class GroupTemplateView(CanEditMixin, DetailFormView):
 | 
					class GroupTemplateView(CanEditMixin, DetailFormView):
 | 
				
			||||||
@@ -127,3 +135,62 @@ class GroupDeleteView(CanEditMixin, DeleteView):
 | 
				
			|||||||
    pk_url_kwarg = "group_id"
 | 
					    pk_url_kwarg = "group_id"
 | 
				
			||||||
    template_name = "core/delete_confirm.jinja"
 | 
					    template_name = "core/delete_confirm.jinja"
 | 
				
			||||||
    success_url = reverse_lazy("core:group_list")
 | 
					    success_url = reverse_lazy("core:group_list")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PermissionGroupsUpdateView(
 | 
				
			||||||
 | 
					    PermissionRequiredMixin, SuccessMessageMixin, UpdateView
 | 
				
			||||||
 | 
					):
 | 
				
			||||||
 | 
					    """Manage the groups that have a specific permission.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Notes:
 | 
				
			||||||
 | 
					        This is an `UpdateView`, but unlike typical `UpdateView`,
 | 
				
			||||||
 | 
					        it doesn't accept url arguments to retrieve the object
 | 
				
			||||||
 | 
					        to update.
 | 
				
			||||||
 | 
					        As such, a `PermissionGroupsUpdateView` can only deal with
 | 
				
			||||||
 | 
					        a single hardcoded permission.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        This is not a limitation, but an on-purpose design,
 | 
				
			||||||
 | 
					        mainly for security matters.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Example:
 | 
				
			||||||
 | 
					        ```python
 | 
				
			||||||
 | 
					        class SubscriptionPermissionView(PermissionGroupsUpdateView):
 | 
				
			||||||
 | 
					            permission = "subscription.add_subscription"
 | 
				
			||||||
 | 
					        ```
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    permission_required = "auth.change_permission"
 | 
				
			||||||
 | 
					    template_name = "core/edit.jinja"
 | 
				
			||||||
 | 
					    form_class = PermissionGroupsForm
 | 
				
			||||||
 | 
					    permission = None
 | 
				
			||||||
 | 
					    success_message = _("Groups have been successfully updated.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_object(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        if not self.permission:
 | 
				
			||||||
 | 
					            raise ImproperlyConfigured(
 | 
				
			||||||
 | 
					                f"{self.__class__.__name__} is missing the permission attribute. "
 | 
				
			||||||
 | 
					                "Please fill it with either a permission string "
 | 
				
			||||||
 | 
					                "or a Permission object."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        if isinstance(self.permission, Permission):
 | 
				
			||||||
 | 
					            return self.permission
 | 
				
			||||||
 | 
					        if isinstance(self.permission, str):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                app_label, codename = self.permission.split(".")
 | 
				
			||||||
 | 
					            except ValueError as e:
 | 
				
			||||||
 | 
					                raise ValueError(
 | 
				
			||||||
 | 
					                    "Permission name should be in the form "
 | 
				
			||||||
 | 
					                    "app_label.permission_codename."
 | 
				
			||||||
 | 
					                ) from e
 | 
				
			||||||
 | 
					            return get_object_or_404(
 | 
				
			||||||
 | 
					                Permission, codename=codename, content_type__app_label=app_label
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        raise TypeError(
 | 
				
			||||||
 | 
					            f"{self.__class__.__name__}.permission "
 | 
				
			||||||
 | 
					            f"must be a string or a permission instance."
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_success_url(self):
 | 
				
			||||||
 | 
					        # if children classes define a success url, return it,
 | 
				
			||||||
 | 
					        # else stay on the same page
 | 
				
			||||||
 | 
					        return self.success_url or self.request.path
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										67
									
								
								core/views/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								core/views/mixins.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
				
			|||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.core.exceptions import ImproperlyConfigured
 | 
				
			||||||
 | 
					from django.views import View
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TabedViewMixin(View):
 | 
				
			||||||
 | 
					    """Basic functions for displaying tabs in the template."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_tabs_title(self):
 | 
				
			||||||
 | 
					        if hasattr(self, "tabs_title"):
 | 
				
			||||||
 | 
					            return self.tabs_title
 | 
				
			||||||
 | 
					        raise ImproperlyConfigured("tabs_title is required")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_current_tab(self):
 | 
				
			||||||
 | 
					        if hasattr(self, "current_tab"):
 | 
				
			||||||
 | 
					            return self.current_tab
 | 
				
			||||||
 | 
					        raise ImproperlyConfigured("current_tab is required")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_list_of_tabs(self):
 | 
				
			||||||
 | 
					        if hasattr(self, "list_of_tabs"):
 | 
				
			||||||
 | 
					            return self.list_of_tabs
 | 
				
			||||||
 | 
					        raise ImproperlyConfigured("list_of_tabs is required")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
 | 
					        kwargs = super().get_context_data(**kwargs)
 | 
				
			||||||
 | 
					        kwargs["tabs_title"] = self.get_tabs_title()
 | 
				
			||||||
 | 
					        kwargs["current_tab"] = self.get_current_tab()
 | 
				
			||||||
 | 
					        kwargs["list_of_tabs"] = self.get_list_of_tabs()
 | 
				
			||||||
 | 
					        return kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class QuickNotifMixin:
 | 
				
			||||||
 | 
					    quick_notif_list = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dispatch(self, request, *arg, **kwargs):
 | 
				
			||||||
 | 
					        # In some cases, the class can stay instanciated, so we need to reset the list
 | 
				
			||||||
 | 
					        self.quick_notif_list = []
 | 
				
			||||||
 | 
					        return super().dispatch(request, *arg, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_success_url(self):
 | 
				
			||||||
 | 
					        ret = super().get_success_url()
 | 
				
			||||||
 | 
					        if hasattr(self, "quick_notif_url_arg"):
 | 
				
			||||||
 | 
					            if "?" in ret:
 | 
				
			||||||
 | 
					                ret += "&" + self.quick_notif_url_arg
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                ret += "?" + self.quick_notif_url_arg
 | 
				
			||||||
 | 
					        return ret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
 | 
					        """Add quick notifications to context."""
 | 
				
			||||||
 | 
					        kwargs = super().get_context_data(**kwargs)
 | 
				
			||||||
 | 
					        kwargs["quick_notifs"] = []
 | 
				
			||||||
 | 
					        for n in self.quick_notif_list:
 | 
				
			||||||
 | 
					            kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
 | 
				
			||||||
 | 
					        for key, val in settings.SITH_QUICK_NOTIF.items():
 | 
				
			||||||
 | 
					            for gk in self.request.GET:
 | 
				
			||||||
 | 
					                if key == gk:
 | 
				
			||||||
 | 
					                    kwargs["quick_notifs"].append(val)
 | 
				
			||||||
 | 
					        return kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AllowFragment:
 | 
				
			||||||
 | 
					    """Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_context_data(self, **kwargs):
 | 
				
			||||||
 | 
					        kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
 | 
				
			||||||
 | 
					        return super().get_context_data(**kwargs)
 | 
				
			||||||
@@ -21,8 +21,13 @@ from django.urls import reverse_lazy
 | 
				
			|||||||
from django.views.generic import DetailView, ListView
 | 
					from django.views.generic import DetailView, ListView
 | 
				
			||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
					from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.auth.mixins import (
 | 
				
			||||||
 | 
					    CanCreateMixin,
 | 
				
			||||||
 | 
					    CanEditMixin,
 | 
				
			||||||
 | 
					    CanEditPropMixin,
 | 
				
			||||||
 | 
					    CanViewMixin,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from core.models import LockError, Page, PageRev
 | 
					from core.models import LockError, Page, PageRev
 | 
				
			||||||
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
 | 
					 | 
				
			||||||
from core.views.forms import PageForm, PagePropForm
 | 
					from core.views.forms import PageForm, PagePropForm
 | 
				
			||||||
from core.views.widgets.markdown import MarkdownInput
 | 
					from core.views.widgets.markdown import MarkdownInput
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -54,14 +54,8 @@ from django.views.generic.dates import MonthMixin, YearMixin
 | 
				
			|||||||
from django.views.generic.edit import FormView, UpdateView
 | 
					from django.views.generic.edit import FormView, UpdateView
 | 
				
			||||||
from honeypot.decorators import check_honeypot
 | 
					from honeypot.decorators import check_honeypot
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
 | 
				
			||||||
from core.models import Gift, Preferences, User
 | 
					from core.models import Gift, Preferences, User
 | 
				
			||||||
from core.views import (
 | 
					 | 
				
			||||||
    CanEditMixin,
 | 
					 | 
				
			||||||
    CanEditPropMixin,
 | 
					 | 
				
			||||||
    CanViewMixin,
 | 
					 | 
				
			||||||
    QuickNotifMixin,
 | 
					 | 
				
			||||||
    TabedViewMixin,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
from core.views.forms import (
 | 
					from core.views.forms import (
 | 
				
			||||||
    GiftForm,
 | 
					    GiftForm,
 | 
				
			||||||
    LoginForm,
 | 
					    LoginForm,
 | 
				
			||||||
@@ -70,8 +64,8 @@ from core.views.forms import (
 | 
				
			|||||||
    UserGroupsForm,
 | 
					    UserGroupsForm,
 | 
				
			||||||
    UserProfileForm,
 | 
					    UserProfileForm,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from core.views.mixins import QuickNotifMixin, TabedViewMixin
 | 
				
			||||||
from counter.models import Refilling, Selling
 | 
					from counter.models import Refilling, Selling
 | 
				
			||||||
from counter.views.student_card import StudentCardFormView
 | 
					 | 
				
			||||||
from eboutic.models import Invoice
 | 
					from eboutic.models import Invoice
 | 
				
			||||||
from subscription.models import Subscription
 | 
					from subscription.models import Subscription
 | 
				
			||||||
from trombi.views import UserTrombiForm
 | 
					from trombi.views import UserTrombiForm
 | 
				
			||||||
@@ -206,7 +200,7 @@ class UserTabsMixin(TabedViewMixin):
 | 
				
			|||||||
                "name": _("Family"),
 | 
					                "name": _("Family"),
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "url": reverse("core:user_pictures", kwargs={"user_id": user.id}),
 | 
					                "url": reverse("sas:user_pictures", kwargs={"user_id": user.id}),
 | 
				
			||||||
                "slug": "pictures",
 | 
					                "slug": "pictures",
 | 
				
			||||||
                "name": _("Pictures"),
 | 
					                "name": _("Pictures"),
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
@@ -303,16 +297,6 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
 | 
				
			|||||||
        return kwargs
 | 
					        return kwargs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
 | 
					 | 
				
			||||||
    """Display a user's pictures."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    model = User
 | 
					 | 
				
			||||||
    pk_url_kwarg = "user_id"
 | 
					 | 
				
			||||||
    context_object_name = "profile"
 | 
					 | 
				
			||||||
    template_name = "core/user_pictures.jinja"
 | 
					 | 
				
			||||||
    current_tab = "pictures"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def delete_user_godfather(request, user_id, godfather_id, is_father):
 | 
					def delete_user_godfather(request, user_id, godfather_id, is_father):
 | 
				
			||||||
    user_is_admin = request.user.is_root or request.user.is_board_member
 | 
					    user_is_admin = request.user.is_root or request.user.is_board_member
 | 
				
			||||||
    if user_id != request.user.id and not user_is_admin:
 | 
					    if user_id != request.user.id and not user_is_admin:
 | 
				
			||||||
@@ -571,6 +555,8 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
 | 
				
			|||||||
        if not hasattr(self.object, "trombi_user"):
 | 
					        if not hasattr(self.object, "trombi_user"):
 | 
				
			||||||
            kwargs["trombi_form"] = UserTrombiForm()
 | 
					            kwargs["trombi_form"] = UserTrombiForm()
 | 
				
			||||||
        if hasattr(self.object, "customer"):
 | 
					        if hasattr(self.object, "customer"):
 | 
				
			||||||
 | 
					            from counter.views.student_card import StudentCardFormView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
 | 
					            kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
 | 
				
			||||||
                self.object.customer
 | 
					                self.object.customer
 | 
				
			||||||
            ).render(self.request)
 | 
					            ).render(self.request)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,7 +20,7 @@ from ninja_extra import ControllerBase, api_controller, paginate, route
 | 
				
			|||||||
from ninja_extra.pagination import PageNumberPaginationExtra
 | 
					from ninja_extra.pagination import PageNumberPaginationExtra
 | 
				
			||||||
from ninja_extra.schemas import PaginatedResponseSchema
 | 
					from ninja_extra.schemas import PaginatedResponseSchema
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
 | 
					from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
 | 
				
			||||||
from counter.models import Counter, Product, ProductType
 | 
					from counter.models import Counter, Product, ProductType
 | 
				
			||||||
from counter.schemas import (
 | 
					from counter.schemas import (
 | 
				
			||||||
    CounterFilterSchema,
 | 
					    CounterFilterSchema,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -52,7 +52,8 @@ class CustomerQuerySet(models.QuerySet):
 | 
				
			|||||||
    def update_amount(self) -> int:
 | 
					    def update_amount(self) -> int:
 | 
				
			||||||
        """Update the amount of all customers selected by this queryset.
 | 
					        """Update the amount of all customers selected by this queryset.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        The result is given as the sum of all refills minus the sum of all purchases.
 | 
					        The result is given as the sum of all refills
 | 
				
			||||||
 | 
					        minus the sum of all purchases paid with the AE account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Returns:
 | 
					        Returns:
 | 
				
			||||||
            The number of updated rows.
 | 
					            The number of updated rows.
 | 
				
			||||||
@@ -73,7 +74,9 @@ class CustomerQuerySet(models.QuerySet):
 | 
				
			|||||||
            .values("res")
 | 
					            .values("res")
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        money_out = Subquery(
 | 
					        money_out = Subquery(
 | 
				
			||||||
            Selling.objects.filter(customer=OuterRef("pk"))
 | 
					            Selling.objects.filter(
 | 
				
			||||||
 | 
					                customer=OuterRef("pk"), payment_method="SITH_ACCOUNT"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
            .values("customer_id")
 | 
					            .values("customer_id")
 | 
				
			||||||
            .annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
 | 
					            .annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
 | 
				
			||||||
            .values("res")
 | 
					            .values("res")
 | 
				
			||||||
@@ -844,11 +847,10 @@ class Selling(models.Model):
 | 
				
			|||||||
        verbose_name = _("selling")
 | 
					        verbose_name = _("selling")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return "Selling: %d x %s (%f) for %s" % (
 | 
					        return (
 | 
				
			||||||
            self.quantity,
 | 
					            f"Selling: {self.quantity} x {self.label} "
 | 
				
			||||||
            self.label,
 | 
					            f"({self.quantity * self.unit_price} €) "
 | 
				
			||||||
            self.quantity * self.unit_price,
 | 
					            f"for {self.customer.user.get_display_name()}"
 | 
				
			||||||
            self.customer.user.get_display_name(),
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, allow_negative=False, **kwargs):
 | 
					    def save(self, *args, allow_negative=False, **kwargs):
 | 
				
			||||||
@@ -1053,7 +1055,7 @@ class CashRegisterSummary(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __getattribute__(self, name):
 | 
					    def __getattribute__(self, name):
 | 
				
			||||||
        if name[:5] == "check":
 | 
					        if name[:5] == "check":
 | 
				
			||||||
            checks = self.items.filter(check=True).order_by("value").all()
 | 
					            checks = self.items.filter(is_check=True).order_by("value").all()
 | 
				
			||||||
        if name == "ten_cents":
 | 
					        if name == "ten_cents":
 | 
				
			||||||
            return self.items.filter(value=0.1, is_check=False).first()
 | 
					            return self.items.filter(value=0.1, is_check=False).first()
 | 
				
			||||||
        elif name == "twenty_cents":
 | 
					        elif name == "twenty_cents":
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -98,3 +98,5 @@ class ProductFilterSchema(FilterSchema):
 | 
				
			|||||||
    is_archived: bool | None = Field(None, q="archived")
 | 
					    is_archived: bool | None = Field(None, q="archived")
 | 
				
			||||||
    buying_groups: set[int] | None = Field(None, q="buying_groups__in")
 | 
					    buying_groups: set[int] | None = Field(None, q="buying_groups__in")
 | 
				
			||||||
    product_type: set[int] | None = Field(None, q="product_type__in")
 | 
					    product_type: set[int] | None = Field(None, q="product_type__in")
 | 
				
			||||||
 | 
					    club: set[int] | None = Field(None, q="club__in")
 | 
				
			||||||
 | 
					    counter: set[int] | None = Field(None, q="counters__in")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -76,7 +76,15 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
 | 
				
			|||||||
    return {
 | 
					    return {
 | 
				
			||||||
      ...super.tomSelectSettings(),
 | 
					      ...super.tomSelectSettings(),
 | 
				
			||||||
      openOnFocus: false,
 | 
					      openOnFocus: false,
 | 
				
			||||||
      searchField: ["code", "text"],
 | 
					      // We make searching on exact code matching a higher priority
 | 
				
			||||||
 | 
					      // We need to manually set weights or it results on an inconsistent
 | 
				
			||||||
 | 
					      // behavior between production and development environment
 | 
				
			||||||
 | 
					      searchField: [
 | 
				
			||||||
 | 
					        // @ts-ignore documentation says it's fine, specified type is wrong
 | 
				
			||||||
 | 
					        { field: "code", weight: 2 },
 | 
				
			||||||
 | 
					        // @ts-ignore documentation says it's fine, specified type is wrong
 | 
				
			||||||
 | 
					        { field: "text", weight: 0.5 },
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,146 +1,141 @@
 | 
				
			|||||||
import { exportToHtml } from "#core:utils/globals";
 | 
					 | 
				
			||||||
import { BasketItem } from "#counter:counter/basket";
 | 
					import { BasketItem } from "#counter:counter/basket";
 | 
				
			||||||
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
 | 
					import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
 | 
				
			||||||
 | 
					import type { CounterProductSelect } from "./components/counter-product-select-index";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
exportToHtml("loadCounter", (config: CounterConfig) => {
 | 
					document.addEventListener("alpine:init", () => {
 | 
				
			||||||
  document.addEventListener("alpine:init", () => {
 | 
					  Alpine.data("counter", (config: CounterConfig) => ({
 | 
				
			||||||
    Alpine.data("counter", () => ({
 | 
					    basket: {} as Record<string, BasketItem>,
 | 
				
			||||||
      basket: {} as Record<string, BasketItem>,
 | 
					    errors: [],
 | 
				
			||||||
      errors: [],
 | 
					    customerBalance: config.customerBalance,
 | 
				
			||||||
      customerBalance: config.customerBalance,
 | 
					    codeField: null as CounterProductSelect | null,
 | 
				
			||||||
      codeField: undefined,
 | 
					    alertMessage: {
 | 
				
			||||||
      alertMessage: {
 | 
					      content: "",
 | 
				
			||||||
        content: "",
 | 
					      show: false,
 | 
				
			||||||
        show: false,
 | 
					      timeout: null,
 | 
				
			||||||
        timeout: null,
 | 
					    },
 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      init() {
 | 
					    init() {
 | 
				
			||||||
        // Fill the basket with the initial data
 | 
					      // Fill the basket with the initial data
 | 
				
			||||||
        for (const entry of config.formInitial) {
 | 
					      for (const entry of config.formInitial) {
 | 
				
			||||||
          if (entry.id !== undefined && entry.quantity !== undefined) {
 | 
					        if (entry.id !== undefined && entry.quantity !== undefined) {
 | 
				
			||||||
            this.addToBasket(entry.id, entry.quantity);
 | 
					          this.addToBasket(entry.id, entry.quantity);
 | 
				
			||||||
            this.basket[entry.id].errors = entry.errors ?? [];
 | 
					          this.basket[entry.id].errors = entry.errors ?? [];
 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.codeField = this.$refs.codeField;
 | 
					      this.codeField = this.$refs.codeField;
 | 
				
			||||||
        this.codeField.widget.focus();
 | 
					      this.codeField.widget.focus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // It's quite tricky to manually apply attributes to the management part
 | 
					      // It's quite tricky to manually apply attributes to the management part
 | 
				
			||||||
        // of a formset so we dynamically apply it here
 | 
					      // of a formset so we dynamically apply it here
 | 
				
			||||||
        this.$refs.basketManagementForm
 | 
					      this.$refs.basketManagementForm
 | 
				
			||||||
          .querySelector("#id_form-TOTAL_FORMS")
 | 
					        .querySelector("#id_form-TOTAL_FORMS")
 | 
				
			||||||
          .setAttribute(":value", "getBasketSize()");
 | 
					        .setAttribute(":value", "getBasketSize()");
 | 
				
			||||||
      },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      removeFromBasket(id: string) {
 | 
					    removeFromBasket(id: string) {
 | 
				
			||||||
 | 
					      delete this.basket[id];
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    addToBasket(id: string, quantity: number): ErrorMessage {
 | 
				
			||||||
 | 
					      const item: BasketItem =
 | 
				
			||||||
 | 
					        this.basket[id] || new BasketItem(config.products[id], 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const oldQty = item.quantity;
 | 
				
			||||||
 | 
					      item.quantity += quantity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (item.quantity <= 0) {
 | 
				
			||||||
        delete this.basket[id];
 | 
					        delete this.basket[id];
 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      addToBasket(id: string, quantity: number): ErrorMessage {
 | 
					 | 
				
			||||||
        const item: BasketItem =
 | 
					 | 
				
			||||||
          this.basket[id] || new BasketItem(config.products[id], 0);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const oldQty = item.quantity;
 | 
					 | 
				
			||||||
        item.quantity += quantity;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (item.quantity <= 0) {
 | 
					 | 
				
			||||||
          delete this.basket[id];
 | 
					 | 
				
			||||||
          return "";
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        this.basket[id] = item;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (this.sumBasket() > this.customerBalance) {
 | 
					 | 
				
			||||||
          item.quantity = oldQty;
 | 
					 | 
				
			||||||
          if (item.quantity === 0) {
 | 
					 | 
				
			||||||
            delete this.basket[id];
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          return gettext("Not enough money");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return "";
 | 
					        return "";
 | 
				
			||||||
      },
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      getBasketSize() {
 | 
					      this.basket[id] = item;
 | 
				
			||||||
        return Object.keys(this.basket).length;
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      sumBasket() {
 | 
					      if (this.sumBasket() > this.customerBalance) {
 | 
				
			||||||
        if (this.getBasketSize() === 0) {
 | 
					        item.quantity = oldQty;
 | 
				
			||||||
          return 0;
 | 
					        if (item.quantity === 0) {
 | 
				
			||||||
 | 
					          delete this.basket[id];
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        const total = Object.values(this.basket).reduce(
 | 
					        return gettext("Not enough money");
 | 
				
			||||||
          (acc: number, cur: BasketItem) => acc + cur.sum(),
 | 
					      }
 | 
				
			||||||
          0,
 | 
					 | 
				
			||||||
        ) as number;
 | 
					 | 
				
			||||||
        return total;
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      showAlertMessage(message: string) {
 | 
					      return "";
 | 
				
			||||||
        if (this.alertMessage.timeout !== null) {
 | 
					    },
 | 
				
			||||||
          clearTimeout(this.alertMessage.timeout);
 | 
					
 | 
				
			||||||
 | 
					    getBasketSize() {
 | 
				
			||||||
 | 
					      return Object.keys(this.basket).length;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    sumBasket() {
 | 
				
			||||||
 | 
					      if (this.getBasketSize() === 0) {
 | 
				
			||||||
 | 
					        return 0;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const total = Object.values(this.basket).reduce(
 | 
				
			||||||
 | 
					        (acc: number, cur: BasketItem) => acc + cur.sum(),
 | 
				
			||||||
 | 
					        0,
 | 
				
			||||||
 | 
					      ) as number;
 | 
				
			||||||
 | 
					      return total;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    showAlertMessage(message: string) {
 | 
				
			||||||
 | 
					      if (this.alertMessage.timeout !== null) {
 | 
				
			||||||
 | 
					        clearTimeout(this.alertMessage.timeout);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.alertMessage.content = message;
 | 
				
			||||||
 | 
					      this.alertMessage.show = true;
 | 
				
			||||||
 | 
					      this.alertMessage.timeout = setTimeout(() => {
 | 
				
			||||||
 | 
					        this.alertMessage.show = false;
 | 
				
			||||||
 | 
					        this.alertMessage.timeout = null;
 | 
				
			||||||
 | 
					      }, 2000);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    addToBasketWithMessage(id: string, quantity: number) {
 | 
				
			||||||
 | 
					      const message = this.addToBasket(id, quantity);
 | 
				
			||||||
 | 
					      if (message.length > 0) {
 | 
				
			||||||
 | 
					        this.showAlertMessage(message);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    onRefillingSuccess(event: CustomEvent) {
 | 
				
			||||||
 | 
					      if (event.type !== "htmx:after-request" || event.detail.failed) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.customerBalance += Number.parseFloat(
 | 
				
			||||||
 | 
					        (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      document.getElementById("selling-accordion").click();
 | 
				
			||||||
 | 
					      this.codeField.widget.focus();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    finish() {
 | 
				
			||||||
 | 
					      if (this.getBasketSize() === 0) {
 | 
				
			||||||
 | 
					        this.showAlertMessage(gettext("You can't send an empty basket."));
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.$refs.basketForm.submit();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cancel() {
 | 
				
			||||||
 | 
					      location.href = config.cancelUrl;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    handleCode() {
 | 
				
			||||||
 | 
					      const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
 | 
				
			||||||
 | 
					        if (code === "ANN") {
 | 
				
			||||||
 | 
					          this.cancel();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.alertMessage.content = message;
 | 
					        if (code === "FIN") {
 | 
				
			||||||
        this.alertMessage.show = true;
 | 
					          this.finish();
 | 
				
			||||||
        this.alertMessage.timeout = setTimeout(() => {
 | 
					 | 
				
			||||||
          this.alertMessage.show = false;
 | 
					 | 
				
			||||||
          this.alertMessage.timeout = null;
 | 
					 | 
				
			||||||
        }, 2000);
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      addToBasketWithMessage(id: string, quantity: number) {
 | 
					 | 
				
			||||||
        const message = this.addToBasket(id, quantity);
 | 
					 | 
				
			||||||
        if (message.length > 0) {
 | 
					 | 
				
			||||||
          this.showAlertMessage(message);
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      },
 | 
					      } else {
 | 
				
			||||||
 | 
					        this.addToBasketWithMessage(code, quantity);
 | 
				
			||||||
      onRefillingSuccess(event: CustomEvent) {
 | 
					      }
 | 
				
			||||||
        if (event.type !== "htmx:after-request" || event.detail.failed) {
 | 
					      this.codeField.widget.clear();
 | 
				
			||||||
          return;
 | 
					      this.codeField.widget.focus();
 | 
				
			||||||
        }
 | 
					    },
 | 
				
			||||||
        this.customerBalance += Number.parseFloat(
 | 
					  }));
 | 
				
			||||||
          (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        document.getElementById("selling-accordion").click();
 | 
					 | 
				
			||||||
        this.codeField.widget.focus();
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      finish() {
 | 
					 | 
				
			||||||
        if (this.getBasketSize() === 0) {
 | 
					 | 
				
			||||||
          this.showAlertMessage(gettext("You can't send an empty basket."));
 | 
					 | 
				
			||||||
          return;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        this.$refs.basketForm.submit();
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      cancel() {
 | 
					 | 
				
			||||||
        location.href = config.cancelUrl;
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      handleCode() {
 | 
					 | 
				
			||||||
        const [quantity, code] = this.codeField.getSelectedProduct() as [
 | 
					 | 
				
			||||||
          number,
 | 
					 | 
				
			||||||
          string,
 | 
					 | 
				
			||||||
        ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
 | 
					 | 
				
			||||||
          if (code === "ANN") {
 | 
					 | 
				
			||||||
            this.cancel();
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          if (code === "FIN") {
 | 
					 | 
				
			||||||
            this.finish();
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          this.addToBasketWithMessage(code, quantity);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        this.codeField.widget.clear();
 | 
					 | 
				
			||||||
        this.codeField.widget.focus();
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    }));
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$(() => {
 | 
					$(() => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -60,6 +60,8 @@ document.addEventListener("alpine:init", () => {
 | 
				
			|||||||
    productStatus: "" as "active" | "archived" | "both",
 | 
					    productStatus: "" as "active" | "archived" | "both",
 | 
				
			||||||
    search: "",
 | 
					    search: "",
 | 
				
			||||||
    productTypes: [] as string[],
 | 
					    productTypes: [] as string[],
 | 
				
			||||||
 | 
					    clubs: [] as string[],
 | 
				
			||||||
 | 
					    counters: [] as string[],
 | 
				
			||||||
    pageSize: defaultPageSize,
 | 
					    pageSize: defaultPageSize,
 | 
				
			||||||
    page: defaultPage,
 | 
					    page: defaultPage,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -67,13 +69,27 @@ document.addEventListener("alpine:init", () => {
 | 
				
			|||||||
      const url = getCurrentUrlParams();
 | 
					      const url = getCurrentUrlParams();
 | 
				
			||||||
      this.search = url.get("search") || "";
 | 
					      this.search = url.get("search") || "";
 | 
				
			||||||
      this.productStatus = url.get("productStatus") ?? "active";
 | 
					      this.productStatus = url.get("productStatus") ?? "active";
 | 
				
			||||||
      const widget = this.$refs.productTypesInput.widget as TomSelect;
 | 
					      const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect;
 | 
				
			||||||
      widget.on("change", (items: string[]) => {
 | 
					      productTypesWidget.on("change", (items: string[]) => {
 | 
				
			||||||
        this.productTypes = [...items];
 | 
					        this.productTypes = [...items];
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					      const clubsWidget = this.$refs.clubsInput.widget as TomSelect;
 | 
				
			||||||
 | 
					      clubsWidget.on("change", (items: string[]) => {
 | 
				
			||||||
 | 
					        this.clubs = [...items];
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      const countersWidget = this.$refs.countersInput.widget as TomSelect;
 | 
				
			||||||
 | 
					      countersWidget.on("change", (items: string[]) => {
 | 
				
			||||||
 | 
					        this.counters = [...items];
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await this.load();
 | 
					      await this.load();
 | 
				
			||||||
      const searchParams = ["search", "productStatus", "productTypes"];
 | 
					      const searchParams = [
 | 
				
			||||||
 | 
					        "search",
 | 
				
			||||||
 | 
					        "productStatus",
 | 
				
			||||||
 | 
					        "productTypes",
 | 
				
			||||||
 | 
					        "clubs",
 | 
				
			||||||
 | 
					        "counters",
 | 
				
			||||||
 | 
					      ];
 | 
				
			||||||
      for (const param of searchParams) {
 | 
					      for (const param of searchParams) {
 | 
				
			||||||
        this.$watch(param, () => {
 | 
					        this.$watch(param, () => {
 | 
				
			||||||
          this.page = defaultPage;
 | 
					          this.page = defaultPage;
 | 
				
			||||||
@@ -109,6 +125,8 @@ document.addEventListener("alpine:init", () => {
 | 
				
			|||||||
          is_archived: isArchived,
 | 
					          is_archived: isArchived,
 | 
				
			||||||
          // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
					          // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
				
			||||||
          product_type: [...this.productTypes],
 | 
					          product_type: [...this.productTypes],
 | 
				
			||||||
 | 
					          club: [...this.clubs],
 | 
				
			||||||
 | 
					          counter: [...this.counters],
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
@@ -121,14 +139,17 @@ document.addEventListener("alpine:init", () => {
 | 
				
			|||||||
      const options = this.getQueryParams();
 | 
					      const options = this.getQueryParams();
 | 
				
			||||||
      const resp = await productSearchProductsDetailed(options);
 | 
					      const resp = await productSearchProductsDetailed(options);
 | 
				
			||||||
      this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
 | 
					      this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
 | 
				
			||||||
      this.products = resp.data.results.reduce<GroupedProducts>((acc, curr) => {
 | 
					      this.products = resp.data.results.reduce<GroupedProducts>(
 | 
				
			||||||
        const key = curr.product_type?.name ?? gettext("Uncategorized");
 | 
					        (acc: GroupedProducts, curr: ProductSchema) => {
 | 
				
			||||||
        if (!(key in acc)) {
 | 
					          const key = curr.product_type?.name ?? gettext("Uncategorized");
 | 
				
			||||||
          acc[key] = [];
 | 
					          if (!(key in acc)) {
 | 
				
			||||||
        }
 | 
					            acc[key] = [];
 | 
				
			||||||
        acc[key].push(curr);
 | 
					          }
 | 
				
			||||||
        return acc;
 | 
					          acc[key].push(curr);
 | 
				
			||||||
      }, {});
 | 
					          return acc;
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        {},
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
      this.loading = false;
 | 
					      this.loading = false;
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,7 +27,13 @@
 | 
				
			|||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
  <h4>{{ counter }}</h4>
 | 
					  <h4>{{ counter }}</h4>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div id="bar-ui" x-data="counter">
 | 
					  <div id="bar-ui" x-data="counter({
 | 
				
			||||||
 | 
					                           customerBalance: {{ customer.amount }},
 | 
				
			||||||
 | 
					                           products: products,
 | 
				
			||||||
 | 
					                           customerId: {{ customer.pk }},
 | 
				
			||||||
 | 
					                           formInitial: formInitial,
 | 
				
			||||||
 | 
					                           cancelUrl: '{{ cancel_url }}',
 | 
				
			||||||
 | 
					                           })">
 | 
				
			||||||
    <noscript>
 | 
					    <noscript>
 | 
				
			||||||
      <p class="important">Javascript is required for the counter UI.</p>
 | 
					      <p class="important">Javascript is required for the counter UI.</p>
 | 
				
			||||||
    </noscript>
 | 
					    </noscript>
 | 
				
			||||||
@@ -255,14 +261,5 @@
 | 
				
			|||||||
        {%- endif -%}
 | 
					        {%- endif -%}
 | 
				
			||||||
      {%- endfor -%}
 | 
					      {%- endfor -%}
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
    window.addEventListener("DOMContentLoaded", () => {
 | 
					 | 
				
			||||||
      loadCounter({
 | 
					 | 
				
			||||||
        customerBalance: {{ customer.amount }},
 | 
					 | 
				
			||||||
        products: products,
 | 
					 | 
				
			||||||
        customerId: {{ customer.pk }},
 | 
					 | 
				
			||||||
        formInitial: formInitial,
 | 
					 | 
				
			||||||
        cancelUrl: "{{ cancel_url }}",
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  </script>
 | 
					  </script>
 | 
				
			||||||
{% endblock script %}
 | 
					{% endblock script %}
 | 
				
			||||||
@@ -7,6 +7,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
{% block additional_js %}
 | 
					{% block additional_js %}
 | 
				
			||||||
  <script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script>
 | 
					  <script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script>
 | 
				
			||||||
 | 
					  <script type="module" src="{{ static("bundled/club/components/ajax-select-index.ts") }}"></script>
 | 
				
			||||||
  <script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
 | 
					  <script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,7 +23,6 @@
 | 
				
			|||||||
    <h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4>
 | 
					    <h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4>
 | 
				
			||||||
    <form id="search-form" class="margin-bottom">
 | 
					    <form id="search-form" class="margin-bottom">
 | 
				
			||||||
      <div class="row gap-4x">
 | 
					      <div class="row gap-4x">
 | 
				
			||||||
 | 
					 | 
				
			||||||
        <fieldset>
 | 
					        <fieldset>
 | 
				
			||||||
          <label for="search-input">{% trans %}Product name{% endtrans %}</label>
 | 
					          <label for="search-input">{% trans %}Product name{% endtrans %}</label>
 | 
				
			||||||
          <input
 | 
					          <input
 | 
				
			||||||
@@ -48,16 +48,34 @@
 | 
				
			|||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </fieldset>
 | 
					        </fieldset>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <fieldset>
 | 
					      <div class="row gap-4x">
 | 
				
			||||||
        <label for="type-search-input">{% trans %}Product type{% endtrans %}</label>
 | 
					        <fieldset class="grow">
 | 
				
			||||||
        <product-type-ajax-select
 | 
					          <label for="type-search-input">{% trans %}Product type{% endtrans %}</label>
 | 
				
			||||||
          id="type-search-input"
 | 
					          <product-type-ajax-select
 | 
				
			||||||
          name="product-type"
 | 
					            id="type-search-input"
 | 
				
			||||||
          x-ref="productTypesInput"
 | 
					            name="product-type"
 | 
				
			||||||
          multiple
 | 
					            x-ref="productTypesInput"
 | 
				
			||||||
        >
 | 
					            multiple
 | 
				
			||||||
        </product-type-ajax-select>
 | 
					          ></product-type-ajax-select>
 | 
				
			||||||
      </fieldset>
 | 
					        </fieldset>
 | 
				
			||||||
 | 
					        <fieldset class="grow">
 | 
				
			||||||
 | 
					          <label for="club-search-input">{% trans %}Clubs{% endtrans %}</label>
 | 
				
			||||||
 | 
					          <club-ajax-select
 | 
				
			||||||
 | 
					            id="club-search-input"
 | 
				
			||||||
 | 
					            name="club"
 | 
				
			||||||
 | 
					            x-ref="clubsInput"
 | 
				
			||||||
 | 
					            multiple></club-ajax-select>
 | 
				
			||||||
 | 
					        </fieldset>
 | 
				
			||||||
 | 
					        <fieldset class="grow">
 | 
				
			||||||
 | 
					          <label for="counter-search-input">{% trans %}Counters{% endtrans %}</label>
 | 
				
			||||||
 | 
					          <counter-ajax-select
 | 
				
			||||||
 | 
					            id="counter-search-input"
 | 
				
			||||||
 | 
					            name="counter"
 | 
				
			||||||
 | 
					            x-ref="countersInput"
 | 
				
			||||||
 | 
					            multiple
 | 
				
			||||||
 | 
					          ></counter-ajax-select>
 | 
				
			||||||
 | 
					        </fieldset>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
    </form>
 | 
					    </form>
 | 
				
			||||||
    <h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
 | 
					    <h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -236,6 +236,10 @@ class TestCounterClick(TestFullClickBase):
 | 
				
			|||||||
            BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
 | 
					            BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        cls.gift = product_recipe.make(
 | 
				
			||||||
 | 
					            selling_price="-1.5",
 | 
				
			||||||
 | 
					            special_selling_price="-1.5",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        cls.beer = product_recipe.make(
 | 
					        cls.beer = product_recipe.make(
 | 
				
			||||||
            limit_age=18, selling_price="1.5", special_selling_price="1"
 | 
					            limit_age=18, selling_price="1.5", special_selling_price="1"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
@@ -253,7 +257,12 @@ class TestCounterClick(TestFullClickBase):
 | 
				
			|||||||
            limit_age=0, selling_price="1.5", special_selling_price="1"
 | 
					            limit_age=0, selling_price="1.5", special_selling_price="1"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        cls.counter.products.add(cls.beer, cls.beer_tap, cls.snack)
 | 
					        cls.cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
 | 
				
			||||||
 | 
					        cls.dcons = Product.objects.get(id=settings.SITH_ECOCUP_DECO)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        cls.counter.products.add(
 | 
				
			||||||
 | 
					            cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        cls.other_counter.products.add(cls.snack)
 | 
					        cls.other_counter.products.add(cls.snack)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -594,6 +603,120 @@ class TestCounterClick(TestFullClickBase):
 | 
				
			|||||||
            else:
 | 
					            else:
 | 
				
			||||||
                assert not counter.has_annotated_barman
 | 
					                assert not counter.has_annotated_barman
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_selling_ordering(self):
 | 
				
			||||||
 | 
					        # Cheaper items should be processed with a higher priority
 | 
				
			||||||
 | 
					        self.login_in_bar(self.barmen)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [
 | 
				
			||||||
 | 
					                    BasketItem(self.beer.id, 1),
 | 
				
			||||||
 | 
					                    BasketItem(self.gift.id, 1),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 302
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert self.updated_amount(self.customer) == 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_recordings(self):
 | 
				
			||||||
 | 
					        self.refill_user(self.customer, self.cons.selling_price * 3)
 | 
				
			||||||
 | 
					        self.login_in_bar(self.barmen)
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [BasketItem(self.cons.id, 3)],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 302
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert self.updated_amount(self.customer) == 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [BasketItem(self.dcons.id, 3)],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 302
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [BasketItem(self.dcons.id, settings.SITH_ECOCUP_LIMIT)],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 302
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert self.updated_amount(self.customer) == self.dcons.selling_price * (
 | 
				
			||||||
 | 
					            -3 - settings.SITH_ECOCUP_LIMIT
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [BasketItem(self.dcons.id, 1)],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 200
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert self.updated_amount(self.customer) == self.dcons.selling_price * (
 | 
				
			||||||
 | 
					            -3 - settings.SITH_ECOCUP_LIMIT
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [
 | 
				
			||||||
 | 
					                    BasketItem(self.cons.id, 1),
 | 
				
			||||||
 | 
					                    BasketItem(self.dcons.id, 1),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 302
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert self.updated_amount(self.customer) == self.dcons.selling_price * (
 | 
				
			||||||
 | 
					            -3 - settings.SITH_ECOCUP_LIMIT
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_recordings_when_negative(self):
 | 
				
			||||||
 | 
					        self.refill_user(
 | 
				
			||||||
 | 
					            self.customer,
 | 
				
			||||||
 | 
					            self.cons.selling_price * 3 + Decimal(self.beer.selling_price),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10
 | 
				
			||||||
 | 
					        self.customer.customer.save()
 | 
				
			||||||
 | 
					        self.login_in_bar(self.barmen)
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [BasketItem(self.dcons.id, 1)],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 200
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert self.updated_amount(
 | 
				
			||||||
 | 
					            self.customer
 | 
				
			||||||
 | 
					        ) == self.cons.selling_price * 3 + Decimal(self.beer.selling_price)
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [BasketItem(self.cons.id, 3)],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 302
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert self.updated_amount(self.customer) == Decimal(self.beer.selling_price)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert (
 | 
				
			||||||
 | 
					            self.submit_basket(
 | 
				
			||||||
 | 
					                self.customer,
 | 
				
			||||||
 | 
					                [BasketItem(self.beer.id, 1)],
 | 
				
			||||||
 | 
					            ).status_code
 | 
				
			||||||
 | 
					            == 302
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert self.updated_amount(self.customer) == 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestCounterStats(TestCase):
 | 
					class TestCounterStats(TestCase):
 | 
				
			||||||
    @classmethod
 | 
					    @classmethod
 | 
				
			||||||
@@ -850,13 +973,23 @@ class TestClubCounterClickAccess(TestCase):
 | 
				
			|||||||
        assert res.status_code == 403
 | 
					        assert res.status_code == 403
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_board_member(self):
 | 
					    def test_board_member(self):
 | 
				
			||||||
 | 
					        """By default, board members should be able to click on office counters"""
 | 
				
			||||||
        baker.make(Membership, club=self.counter.club, user=self.user, role=3)
 | 
					        baker.make(Membership, club=self.counter.club, user=self.user, role=3)
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
        res = self.client.get(self.click_url)
 | 
					        res = self.client.get(self.click_url)
 | 
				
			||||||
        assert res.status_code == 200
 | 
					        assert res.status_code == 200
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_barman(self):
 | 
					    def test_barman(self):
 | 
				
			||||||
 | 
					        """Sellers should be able to click on office counters"""
 | 
				
			||||||
        self.counter.sellers.add(self.user)
 | 
					        self.counter.sellers.add(self.user)
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
        res = self.client.get(self.click_url)
 | 
					        res = self.client.get(self.click_url)
 | 
				
			||||||
        assert res.status_code == 403
 | 
					        assert res.status_code == 200
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_both_barman_and_board_member(self):
 | 
				
			||||||
 | 
					        """If the user is barman and board member, he should be authorized as well."""
 | 
				
			||||||
 | 
					        self.counter.sellers.add(self.user)
 | 
				
			||||||
 | 
					        baker.make(Membership, club=self.counter.club, user=self.user, role=3)
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        res = self.client.get(self.click_url)
 | 
				
			||||||
 | 
					        assert res.status_code == 200
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -442,6 +442,7 @@ def test_update_balance():
 | 
				
			|||||||
            _quantity=len(customers),
 | 
					            _quantity=len(customers),
 | 
				
			||||||
            unit_price=10,
 | 
					            unit_price=10,
 | 
				
			||||||
            quantity=1,
 | 
					            quantity=1,
 | 
				
			||||||
 | 
					            payment_method="SITH_ACCOUNT",
 | 
				
			||||||
            _save_related=True,
 | 
					            _save_related=True,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        *sale_recipe.prepare(
 | 
					        *sale_recipe.prepare(
 | 
				
			||||||
@@ -449,10 +450,26 @@ def test_update_balance():
 | 
				
			|||||||
            _quantity=3,
 | 
					            _quantity=3,
 | 
				
			||||||
            unit_price=5,
 | 
					            unit_price=5,
 | 
				
			||||||
            quantity=2,
 | 
					            quantity=2,
 | 
				
			||||||
 | 
					            payment_method="SITH_ACCOUNT",
 | 
				
			||||||
            _save_related=True,
 | 
					            _save_related=True,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        sale_recipe.prepare(
 | 
					        sale_recipe.prepare(
 | 
				
			||||||
            customer=customers[4], quantity=1, unit_price=50, _save_related=True
 | 
					            customer=customers[4],
 | 
				
			||||||
 | 
					            quantity=1,
 | 
				
			||||||
 | 
					            unit_price=50,
 | 
				
			||||||
 | 
					            payment_method="SITH_ACCOUNT",
 | 
				
			||||||
 | 
					            _save_related=True,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        *sale_recipe.prepare(
 | 
				
			||||||
 | 
					            # all customers also bought products without using their AE account.
 | 
				
			||||||
 | 
					            # All purchases made with another mean than the AE account should
 | 
				
			||||||
 | 
					            # be ignored when updating the account balance.
 | 
				
			||||||
 | 
					            customer=iter(customers),
 | 
				
			||||||
 | 
					            _quantity=len(customers),
 | 
				
			||||||
 | 
					            unit_price=50,
 | 
				
			||||||
 | 
					            quantity=1,
 | 
				
			||||||
 | 
					            payment_method="CARD",
 | 
				
			||||||
 | 
					            _save_related=True,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
    Selling.objects.bulk_create(sales)
 | 
					    Selling.objects.bulk_create(sales)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,8 +24,8 @@ from django.utils import timezone
 | 
				
			|||||||
from django.views.generic import DetailView, ListView, TemplateView
 | 
					from django.views.generic import DetailView, ListView, TemplateView
 | 
				
			||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
					from django.views.generic.edit import CreateView, DeleteView, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.auth.mixins import CanEditMixin, CanViewMixin
 | 
				
			||||||
from core.utils import get_semester_code, get_start_of_semester
 | 
					from core.utils import get_semester_code, get_start_of_semester
 | 
				
			||||||
from core.views import CanEditMixin, CanViewMixin
 | 
					 | 
				
			||||||
from counter.forms import CounterEditForm, ProductEditForm
 | 
					from counter.forms import CounterEditForm, ProductEditForm
 | 
				
			||||||
from counter.models import Counter, Product, ProductType, Refilling, Selling
 | 
					from counter.models import Counter, Product, ProductType, Refilling, Selling
 | 
				
			||||||
from counter.utils import is_logged_in_counter
 | 
					from counter.utils import is_logged_in_counter
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,7 +23,7 @@ from django.utils.translation import gettext_lazy as _
 | 
				
			|||||||
from django.views.generic import DetailView, ListView
 | 
					from django.views.generic import DetailView, ListView
 | 
				
			||||||
from django.views.generic.edit import UpdateView
 | 
					from django.views.generic.edit import UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.views import CanViewMixin
 | 
					from core.auth.mixins import CanViewMixin
 | 
				
			||||||
from counter.forms import CashSummaryFormBase
 | 
					from counter.forms import CashSummaryFormBase
 | 
				
			||||||
from counter.models import (
 | 
					from counter.models import (
 | 
				
			||||||
    CashRegisterSummary,
 | 
					    CashRegisterSummary,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,9 +31,9 @@ from django.views.generic import FormView
 | 
				
			|||||||
from django.views.generic.detail import SingleObjectMixin
 | 
					from django.views.generic.detail import SingleObjectMixin
 | 
				
			||||||
from ninja.main import HttpRequest
 | 
					from ninja.main import HttpRequest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.auth.mixins import CanViewMixin
 | 
				
			||||||
from core.models import User
 | 
					from core.models import User
 | 
				
			||||||
from core.utils import FormFragmentTemplateData
 | 
					from core.utils import FormFragmentTemplateData
 | 
				
			||||||
from core.views import CanViewMixin
 | 
					 | 
				
			||||||
from counter.forms import RefillForm
 | 
					from counter.forms import RefillForm
 | 
				
			||||||
from counter.models import Counter, Customer, Product, Selling
 | 
					from counter.models import Counter, Customer, Product, Selling
 | 
				
			||||||
from counter.utils import is_logged_in_counter
 | 
					from counter.utils import is_logged_in_counter
 | 
				
			||||||
@@ -126,6 +126,11 @@ class BaseBasketForm(BaseFormSet):
 | 
				
			|||||||
            if form.product.is_unrecord_product:
 | 
					            if form.product.is_unrecord_product:
 | 
				
			||||||
                self.total_recordings += form.cleaned_data["quantity"]
 | 
					                self.total_recordings += form.cleaned_data["quantity"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # We don't want to block an user that have negative recordings
 | 
				
			||||||
 | 
					        # if he isn't recording anything or reducing it's recording count
 | 
				
			||||||
 | 
					        if self.total_recordings <= 0:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if not customer.can_record_more(self.total_recordings):
 | 
					        if not customer.can_record_more(self.total_recordings):
 | 
				
			||||||
            raise ValidationError(_("This user have reached his recording limit"))
 | 
					            raise ValidationError(_("This user have reached his recording limit"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -142,15 +147,16 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
 | 
				
			|||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    model = Counter
 | 
					    model = Counter
 | 
				
			||||||
    queryset = Counter.objects.annotate_is_open()
 | 
					    queryset = (
 | 
				
			||||||
 | 
					        Counter.objects.exclude(type="EBOUTIC")
 | 
				
			||||||
 | 
					        .annotate_is_open()
 | 
				
			||||||
 | 
					        .select_related("club")
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    form_class = BasketForm
 | 
					    form_class = BasketForm
 | 
				
			||||||
    template_name = "counter/counter_click.jinja"
 | 
					    template_name = "counter/counter_click.jinja"
 | 
				
			||||||
    pk_url_kwarg = "counter_id"
 | 
					    pk_url_kwarg = "counter_id"
 | 
				
			||||||
    current_tab = "counter"
 | 
					    current_tab = "counter"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					 | 
				
			||||||
        return super().get_queryset().exclude(type="EBOUTIC").annotate_is_open()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_form_kwargs(self):
 | 
					    def get_form_kwargs(self):
 | 
				
			||||||
        kwargs = super().get_form_kwargs()
 | 
					        kwargs = super().get_form_kwargs()
 | 
				
			||||||
        kwargs["form_kwargs"] = {
 | 
					        kwargs["form_kwargs"] = {
 | 
				
			||||||
@@ -168,9 +174,15 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
 | 
				
			|||||||
            return redirect(obj)  # Redirect to counter
 | 
					            return redirect(obj)  # Redirect to counter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if obj.type == "OFFICE" and (
 | 
					        if obj.type == "OFFICE" and (
 | 
				
			||||||
            obj.sellers.filter(pk=request.user.pk).exists()
 | 
					            request.user.is_anonymous
 | 
				
			||||||
            or not obj.club.has_rights_in_club(request.user)
 | 
					            or not (
 | 
				
			||||||
 | 
					                obj.sellers.contains(request.user)
 | 
				
			||||||
 | 
					                or obj.club.has_rights_in_club(request.user)
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
        ):
 | 
					        ):
 | 
				
			||||||
 | 
					            # To be able to click on an office counter,
 | 
				
			||||||
 | 
					            # a user must either be in the board of the club that own the counter
 | 
				
			||||||
 | 
					            # or a seller of this counter.
 | 
				
			||||||
            raise PermissionDenied
 | 
					            raise PermissionDenied
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if obj.type == "BAR" and (
 | 
					        if obj.type == "BAR" and (
 | 
				
			||||||
@@ -194,7 +206,11 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
 | 
				
			|||||||
        with transaction.atomic():
 | 
					        with transaction.atomic():
 | 
				
			||||||
            self.request.session["last_basket"] = []
 | 
					            self.request.session["last_basket"] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            for form in formset:
 | 
					            # We sort items from cheap to expensive
 | 
				
			||||||
 | 
					            # This is important because some items have a negative price
 | 
				
			||||||
 | 
					            # Negative priced items gives money to the customer and should
 | 
				
			||||||
 | 
					            # be processed first so that we don't throw a not enough money error
 | 
				
			||||||
 | 
					            for form in sorted(formset, key=lambda form: form.product.price):
 | 
				
			||||||
                self.request.session["last_basket"].append(
 | 
					                self.request.session["last_basket"].append(
 | 
				
			||||||
                    f"{form.cleaned_data['quantity']} x {form.product.name}"
 | 
					                    f"{form.cleaned_data['quantity']} x {form.product.name}"
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,7 @@ from django.utils.translation import gettext_lazy as _
 | 
				
			|||||||
from django.views.generic import DetailView, ListView
 | 
					from django.views.generic import DetailView, ListView
 | 
				
			||||||
from django.views.generic.edit import CreateView, UpdateView
 | 
					from django.views.generic.edit import CreateView, UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.views import CanViewMixin
 | 
					from core.auth.mixins import CanViewMixin
 | 
				
			||||||
from counter.forms import EticketForm
 | 
					from counter.forms import EticketForm
 | 
				
			||||||
from counter.models import Eticket, Selling
 | 
					from counter.models import Eticket, Selling
 | 
				
			||||||
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
 | 
					from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,7 +22,7 @@ from django.utils.translation import gettext_lazy as _
 | 
				
			|||||||
from django.views.generic import DetailView
 | 
					from django.views.generic import DetailView
 | 
				
			||||||
from django.views.generic.edit import FormMixin, ProcessFormView
 | 
					from django.views.generic.edit import FormMixin, ProcessFormView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.views import CanViewMixin
 | 
					from core.auth.mixins import CanViewMixin
 | 
				
			||||||
from core.views.forms import LoginForm
 | 
					from core.views.forms import LoginForm
 | 
				
			||||||
from counter.forms import GetUserForm
 | 
					from counter.forms import GetUserForm
 | 
				
			||||||
from counter.models import Counter
 | 
					from counter.models import Counter
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,7 @@ from django.urls import reverse_lazy
 | 
				
			|||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from django.views.generic.base import View
 | 
					from django.views.generic.base import View
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.views import TabedViewMixin
 | 
					from core.views.mixins import TabedViewMixin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CounterAdminMixin(View):
 | 
					class CounterAdminMixin(View):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,8 +21,8 @@ from django.urls import reverse
 | 
				
			|||||||
from django.utils.translation import gettext as _
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
from django.views.generic.edit import DeleteView, FormView
 | 
					from django.views.generic.edit import DeleteView, FormView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.auth.mixins import can_edit
 | 
				
			||||||
from core.utils import FormFragmentTemplateData
 | 
					from core.utils import FormFragmentTemplateData
 | 
				
			||||||
from core.views import can_edit
 | 
					 | 
				
			||||||
from counter.forms import StudentCardForm
 | 
					from counter.forms import StudentCardForm
 | 
				
			||||||
from counter.models import Customer, StudentCard
 | 
					from counter.models import Customer, StudentCard
 | 
				
			||||||
from counter.utils import is_logged_in_counter
 | 
					from counter.utils import is_logged_in_counter
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,13 +2,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
Pour connecter l'application à une instance de sentry (ex: https://sentry.io),
 | 
					Pour connecter l'application à une instance de sentry (ex: https://sentry.io),
 | 
				
			||||||
il est nécessaire de configurer la variable `SENTRY_DSN`
 | 
					il est nécessaire de configurer la variable `SENTRY_DSN`
 | 
				
			||||||
dans le fichier `settings_custom.py`.
 | 
					dans le fichier `.env`.
 | 
				
			||||||
Cette variable est composée d'un lien complet vers votre projet sentry.
 | 
					Cette variable est composée d'un lien complet vers votre projet sentry.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Récupérer les statiques
 | 
					## Récupérer les statiques
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Nous utilisons du SCSS dans le projet.
 | 
					Nous utilisons du SCSS dans le projet.
 | 
				
			||||||
En environnement de développement (`DEBUG=True`),
 | 
					En environnement de développement (`SITH_DEBUG=true`),
 | 
				
			||||||
le SCSS est compilé à chaque fois que le fichier est demandé.
 | 
					le SCSS est compilé à chaque fois que le fichier est demandé.
 | 
				
			||||||
Pour la production, le projet considère 
 | 
					Pour la production, le projet considère 
 | 
				
			||||||
que chacun des fichiers est déjà compilé.
 | 
					que chacun des fichiers est déjà compilé.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1 +0,0 @@
 | 
				
			|||||||
::: core.api_permissions
 | 
					 | 
				
			||||||
							
								
								
									
										32
									
								
								docs/reference/core/auth.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								docs/reference/core/auth.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
				
			|||||||
 | 
					## Backend
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					::: core.auth.backends
 | 
				
			||||||
 | 
					    handler: python
 | 
				
			||||||
 | 
					    options:
 | 
				
			||||||
 | 
					        heading_level: 3
 | 
				
			||||||
 | 
					        members:
 | 
				
			||||||
 | 
					            - SithModelBackend
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Mixins
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					::: core.auth.mixins
 | 
				
			||||||
 | 
					    handler: python
 | 
				
			||||||
 | 
					    options:
 | 
				
			||||||
 | 
					        heading_level: 3
 | 
				
			||||||
 | 
					        members:
 | 
				
			||||||
 | 
					            - can_edit_prop
 | 
				
			||||||
 | 
					            - can_edit
 | 
				
			||||||
 | 
					            - can_view
 | 
				
			||||||
 | 
					            - CanCreateMixin
 | 
				
			||||||
 | 
					            - CanEditMixin
 | 
				
			||||||
 | 
					            - CanViewMixin
 | 
				
			||||||
 | 
					            - FormerSubscriberMixin
 | 
				
			||||||
 | 
					            - PermissionOrAuthorRequiredMixin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## API Permissions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					::: core.auth.api_permissions
 | 
				
			||||||
 | 
					    handler: python
 | 
				
			||||||
 | 
					    options:
 | 
				
			||||||
 | 
					        heading_level: 3
 | 
				
			||||||
@@ -157,7 +157,9 @@ il est automatiquement ajouté au groupe des membres
 | 
				
			|||||||
du club.
 | 
					du club.
 | 
				
			||||||
Lorsqu'il quitte le club, il est retiré du groupe.
 | 
					Lorsqu'il quitte le club, il est retiré du groupe.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Les principaux groupes utilisés
 | 
					## Les groupes utilisés
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Groupes principaux
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Les groupes les plus notables gérables par les administrateurs du site sont :
 | 
					Les groupes les plus notables gérables par les administrateurs du site sont :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -168,15 +170,96 @@ Les groupes les plus notables gérables par les administrateurs du site sont :
 | 
				
			|||||||
- `SAS admin` : les administrateurs du SAS
 | 
					- `SAS admin` : les administrateurs du SAS
 | 
				
			||||||
- `Forum admin` : les administrateurs du forum
 | 
					- `Forum admin` : les administrateurs du forum
 | 
				
			||||||
- `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs)
 | 
					- `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					En plus de ces groupes, on peut noter :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `Public` : tous les utilisateurs du site.
 | 
				
			||||||
 | 
					  Un utilisateur est automatiquement ajouté à ce group
 | 
				
			||||||
 | 
					  lors de la création de son compte.
 | 
				
			||||||
 | 
					- `Subscribers` : tous les cotisants du site.
 | 
				
			||||||
 | 
					  Les utilisateurs ne sont pas réellement ajoutés ce groupe ;
 | 
				
			||||||
 | 
					  cependant, les utilisateurs cotisants sont implicitement
 | 
				
			||||||
 | 
					  considérés comme membres du groupe lors de l'appel
 | 
				
			||||||
 | 
					  à la méthode `User.has_perm`.
 | 
				
			||||||
 | 
					- `Old subscribers` : tous les anciens cotisants.
 | 
				
			||||||
 | 
					  Un utilisateur est automatiquement ajouté à ce groupe 
 | 
				
			||||||
 | 
					  lors de sa première cotisation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!!note "Utilisation du groupe Public"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Le groupe Public est un groupe particulier.
 | 
				
			||||||
 | 
					    Tout le monde faisant partie de ce groupe
 | 
				
			||||||
 | 
					    (même les utilisateurs non-connectés en sont implicitement 
 | 
				
			||||||
 | 
					    considérés comme membres),
 | 
				
			||||||
 | 
					    il ne doit pas être utilisé pour résoudre les
 | 
				
			||||||
 | 
					    permissions d'une vue.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    En revanche, il est utile pour attribuer une ressource
 | 
				
			||||||
 | 
					    à tout le monde.
 | 
				
			||||||
 | 
					    Par exemple, un produit avec le groupe de vente Public
 | 
				
			||||||
 | 
					    est considéré comme achetable par tous utilisateurs.
 | 
				
			||||||
 | 
					    S'il n'avait eu aucun group de vente, il n'aurait
 | 
				
			||||||
 | 
					    été accessible à personne.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Groupes de club
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Chaque club est associé à deux groupes :
 | 
				
			||||||
 | 
					le groupe des membres et le groupe du bureau.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Lorsqu'un utilisateur rejoint un club, il est automatiquement
 | 
				
			||||||
 | 
					ajouté au groupe des membres.
 | 
				
			||||||
 | 
					S'il rejoint le club en tant que membre du bureau,
 | 
				
			||||||
 | 
					il est également ajouté au groupe du bureau.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Lorsqu'un utilisateur quitte le club, il est automatiquement
 | 
				
			||||||
 | 
					retiré des groupes liés au club.
 | 
				
			||||||
 | 
					S'il quitte le bureau, mais reste dans le club, 
 | 
				
			||||||
 | 
					il est retiré du groupe du bureau, mais reste dans le groupe des membres.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Groupes de ban
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Les groupes de ban sont une catégorie de groupes à part,
 | 
				
			||||||
 | 
					qui ne sont pas stockés dans la même table 
 | 
				
			||||||
 | 
					et qui ne sont pas gérés sur la même interface
 | 
				
			||||||
 | 
					que les autres groupes.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Les groupes de ban existants sont les suivants :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
 | 
					- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
 | 
				
			||||||
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
 | 
					- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
 | 
				
			||||||
- `Banned to subscribe` : les utilisateurs interdits de cotisation
 | 
					- `Banned to subscribe` : les utilisateurs interdits de cotisation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Groupes liés à une permission
 | 
				
			||||||
 | 
					
 | 
				
			||||||
En plus de ces groupes, on peut noter :
 | 
					Certaines actions sur le site demandent une permission en particulier,
 | 
				
			||||||
 | 
					que l'on veut donner ou retirer n'importe quand.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- `Public` : tous les utilisateurs du site
 | 
					Prenons par exemple les cotisations : lors de l'intégration,
 | 
				
			||||||
- `Subscribers` : tous les cotisants du site
 | 
					on veut permettre aux membres du bureau de l'Integ
 | 
				
			||||||
- `Old subscribers` : tous les anciens cotisants
 | 
					de créer des cotisations, et pareil pour les membres du bureau 
 | 
				
			||||||
 | 
					de la Welcome Week pendant cette dernière.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Dans ces cas-là, il est pertinent de mettre à disposition
 | 
				
			||||||
 | 
					des administrateurs du site une page leur permettant
 | 
				
			||||||
 | 
					de gérer quels groupes ont une permission donnée.
 | 
				
			||||||
 | 
					Pour ce faire, il existe 
 | 
				
			||||||
 | 
					[PermissionGroupsUpdateView][core.views.PermissionGroupsUpdateView].
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Pour l'utiliser, il suffit de créer une vue qui en hérite
 | 
				
			||||||
 | 
					et de lui dire quelle est la permission dont on veut gérer
 | 
				
			||||||
 | 
					les groupes :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					from core.views.group import PermissionGroupsUpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SubscriptionPermissionView(PermissionGroupsUpdateView):
 | 
				
			||||||
 | 
					    permission = "subscription.add_subscription"
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Configurez l'url de la vue, et c'est tout !
 | 
				
			||||||
 | 
					La page ainsi générée contiendra un formulaire
 | 
				
			||||||
 | 
					avec un unique champ permettant de sélectionner des groupes.
 | 
				
			||||||
 | 
					Par défaut, seuls les utilisateurs avec la permission
 | 
				
			||||||
 | 
					`auth.change_permission` auront accès à ce formulaire
 | 
				
			||||||
 | 
					(donc, normalement, uniquement les utilisateurs Root).
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -47,19 +47,19 @@ Commencez par installer les dépendances système :
 | 
				
			|||||||
    === "Debian/Ubuntu"
 | 
					    === "Debian/Ubuntu"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        ```bash
 | 
					        ```bash
 | 
				
			||||||
        sudo apt install postgresql redis libq-dev nginx
 | 
					        sudo apt install postgresql libq-dev nginx
 | 
				
			||||||
        ```
 | 
					        ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    === "Arch Linux"
 | 
					    === "Arch Linux"
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
        ```bash
 | 
					        ```bash
 | 
				
			||||||
        sudo pacman -S postgresql redis nginx
 | 
					        sudo pacman -S postgresql nginx
 | 
				
			||||||
        ```
 | 
					        ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=== "macOS"
 | 
					=== "macOS"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ```bash
 | 
					    ```bash
 | 
				
			||||||
    brew install postgresql redis lipbq nginx
 | 
					    brew install postgresql lipbq nginx
 | 
				
			||||||
    export PATH="/usr/local/opt/libpq/bin:$PATH"
 | 
					    export PATH="/usr/local/opt/libpq/bin:$PATH"
 | 
				
			||||||
    source ~/.zshrc
 | 
					    source ~/.zshrc
 | 
				
			||||||
    ```
 | 
					    ```
 | 
				
			||||||
@@ -77,32 +77,56 @@ uv sync --group prod
 | 
				
			|||||||
    C'est parce que ces dépendances compilent certains modules
 | 
					    C'est parce que ces dépendances compilent certains modules
 | 
				
			||||||
    à l'installation.
 | 
					    à l'installation.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Configurer Redis
 | 
					## Désactiver Honcho
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Redis est utilisé comme cache.
 | 
					Honcho est utilisé en développement pour simplifier la gestion
 | 
				
			||||||
Assurez-vous qu'il tourne :
 | 
					des services externes (redis, vite et autres futures).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					En mode production, il est nécessaire de le désactiver puisque normalement
 | 
				
			||||||
sudo systemctl redis status
 | 
					tous ces services sont déjà configurés.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Pour désactiver Honcho il suffit de ne sélectionner aucun `PROCFILE_` dans la config.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```dotenv
 | 
				
			||||||
 | 
					PROCFILE_STATIC=
 | 
				
			||||||
 | 
					PROCFILE_SERVICE=
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Et s'il ne tourne pas, démarrez-le :
 | 
					!!! note
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Si `PROCFILE_STATIC` est désactivé, la recompilation automatique
 | 
				
			||||||
 | 
					    des fichiers statiques ne se fait plus.
 | 
				
			||||||
 | 
					    Si vous en avez besoin et que vous travaillez sans `PROCFILE_STATIC`,
 | 
				
			||||||
 | 
					    vous devez ouvrir une autre fenêtre de votre terminal
 | 
				
			||||||
 | 
					    et lancer la commande `npm run serve`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Configurer Redis en service externe
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Redis est installé comme dépendance mais pas lancé par défaut.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					En mode développement, le sith se charge de le démarrer mais
 | 
				
			||||||
 | 
					pas en production !
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Il faut donc lancer le service comme ceci:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					```bash
 | 
				
			||||||
sudo systemctl start redis
 | 
					sudo systemctl start redis
 | 
				
			||||||
sudo systemctl enable redis  # si vous voulez que redis démarre automatiquement au boot
 | 
					sudo systemctl enable redis  # si vous voulez que redis démarre automatiquement au boot
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Puis ajoutez le code suivant à la fin de votre fichier
 | 
					Puis modifiez votre `.env` pour y configurer le bon port redis.
 | 
				
			||||||
`settings_custom.py` :
 | 
					Le port du fichier d'exemple est un port non standard pour éviter
 | 
				
			||||||
 | 
					les conflits avec les instances de redis déjà en fonctionnement.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```python
 | 
					```dotenv
 | 
				
			||||||
CACHES = {
 | 
					REDIS_PORT=6379
 | 
				
			||||||
    "default": {
 | 
					CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
 | 
				
			||||||
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
 | 
					```
 | 
				
			||||||
        "LOCATION": "redis://127.0.0.1:6379",
 | 
					
 | 
				
			||||||
    }
 | 
					Si on souhaite configurer redis pour communiquer via un socket :
 | 
				
			||||||
}
 | 
					
 | 
				
			||||||
 | 
					```dovenv
 | 
				
			||||||
 | 
					CACHE_URL=redis:///path/to/redis-server.sock
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Configurer PostgreSQL
 | 
					## Configurer PostgreSQL
 | 
				
			||||||
@@ -139,26 +163,19 @@ en étant connecté en tant que postgres :
 | 
				
			|||||||
psql -d sith -c "GRANT ALL PRIVILEGES ON SCHEMA public to sith";
 | 
					psql -d sith -c "GRANT ALL PRIVILEGES ON SCHEMA public to sith";
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Puis ajoutez le code suivant à la fin de votre
 | 
					Puis modifiez votre `.env`.
 | 
				
			||||||
`settings_custom.py` :
 | 
					Dedans, décommentez l'url de la base de données
 | 
				
			||||||
 | 
					de postgres et commentez l'url de sqlite :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```python
 | 
					```dotenv
 | 
				
			||||||
DATABASES = {
 | 
					#DATABASE_URL=sqlite:///db.sqlite3
 | 
				
			||||||
    "default": {
 | 
					DATABASE_URL=postgres://sith:password@localhost:5432/sith
 | 
				
			||||||
        "ENGINE": "django.db.backends.postgresql",
 | 
					 | 
				
			||||||
        "NAME": "sith",
 | 
					 | 
				
			||||||
        "USER": "sith",
 | 
					 | 
				
			||||||
        "PASSWORD": "password",
 | 
					 | 
				
			||||||
        "HOST": "localhost",
 | 
					 | 
				
			||||||
        "PORT": "",  # laissez ce champ vide pour que le choix du port soit automatique
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Enfin, créez vos données :
 | 
					Enfin, créez vos données :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					```bash
 | 
				
			||||||
uv run ./manage.py populate
 | 
					uv run ./manage.py setup
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
!!! note
 | 
					!!! note
 | 
				
			||||||
@@ -247,7 +264,7 @@ Puis lancez ou relancez nginx :
 | 
				
			|||||||
sudo systemctl restart nginx
 | 
					sudo systemctl restart nginx
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Dans votre `settings_custom.py`, remplacez `DEBUG=True` par `DEBUG=False`.
 | 
					Dans votre `.env`, remplacez `SITH_DEBUG=true` par `SITH_DEBUG=false`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Enfin, démarrez le serveur Django :
 | 
					Enfin, démarrez le serveur Django :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -259,7 +276,7 @@ uv run ./manage.py runserver 8001
 | 
				
			|||||||
Et c'est bon, votre reverse-proxy est prêt à tourner devant votre serveur.
 | 
					Et c'est bon, votre reverse-proxy est prêt à tourner devant votre serveur.
 | 
				
			||||||
Nginx écoutera sur le port 8000.
 | 
					Nginx écoutera sur le port 8000.
 | 
				
			||||||
Toutes les requêtes vers des fichiers statiques et les medias publiques
 | 
					Toutes les requêtes vers des fichiers statiques et les medias publiques
 | 
				
			||||||
seront seront servies directement par nginx.
 | 
					seront servies directement par nginx.
 | 
				
			||||||
Toutes les autres requêtes seront transmises au serveur django.
 | 
					Toutes les autres requêtes seront transmises au serveur django.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -273,3 +290,64 @@ un cron pour la mettre à jour au moins une fois par jour.
 | 
				
			|||||||
```bash
 | 
					```bash
 | 
				
			||||||
python manage.py update_spam_database
 | 
					python manage.py update_spam_database
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Personnaliser l'environnement
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Le site utilise beaucoup de variables configurables via l'environnement.
 | 
				
			||||||
 | 
					Cependant, pour des raisons de maintenabilité et de simplicité
 | 
				
			||||||
 | 
					pour les nouveaux développeurs, nous n'avons mis dans le fichier
 | 
				
			||||||
 | 
					`.env.example` que celles qui peuvent nécessiter d'être fréquemment modifiées
 | 
				
			||||||
 | 
					(par exemple, l'url de connexion à la db, ou l'activation du mode debug).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Cependant, il en existe beaucoup d'autres, que vous pouvez trouver
 | 
				
			||||||
 | 
					dans le `settings.py` en recherchant `env.` 
 | 
				
			||||||
 | 
					(avec `grep` ou avec un ++ctrl+f++ dans votre éditeur).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Si le besoin de les modifier se présente, c'est chose possible.
 | 
				
			||||||
 | 
					Il suffit de rajouter la paire clef-valeur correspondante dans le `.env`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!!tip
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Si vous utilisez nushell, 
 | 
				
			||||||
 | 
					    vous pouvez automatiser le processus avec
 | 
				
			||||||
 | 
					    avec le script suivant, qui va parser le `settings.py`
 | 
				
			||||||
 | 
					    pour récupérer toutes les variables d'environnement qui ne sont pas
 | 
				
			||||||
 | 
					    définies dans le .env puis va les rajouter :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ```nu
 | 
				
			||||||
 | 
					    # si le fichier .env n'existe pas, on le crée
 | 
				
			||||||
 | 
					    if not (".env" | path exists) {
 | 
				
			||||||
 | 
					        cp .env.example .env
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # puis on récupère les variables d'environnement déjà existantes
 | 
				
			||||||
 | 
					    let existing = open .env 
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # on récupère toutes les variables d'environnement utilisées
 | 
				
			||||||
 | 
					    # dans le settings.py qui ne sont pas encore définies dans le .env,
 | 
				
			||||||
 | 
					    # on les convertit dans un format .env,
 | 
				
			||||||
 | 
					    # puis on les ajoute à la fin du .env
 | 
				
			||||||
 | 
					    let regex = '(env\.)(?<method>\w+)\(\s*"(?<env_name>\w+)"(\s*(, default=)(?<value>.+))?\s*\)';
 | 
				
			||||||
 | 
					    let content = open sith/settings.py;
 | 
				
			||||||
 | 
					    let vars = $content
 | 
				
			||||||
 | 
					        | parse --regex $regex 
 | 
				
			||||||
 | 
					        | filter { |i| $i.env_name not-in $existing }
 | 
				
			||||||
 | 
					        | each { |i|
 | 
				
			||||||
 | 
					            let parsed_value = match [$i.method, $i.value] {
 | 
				
			||||||
 | 
					                ["str", "None"] => ""
 | 
				
			||||||
 | 
					                ["bool", $val] => ($val | str downcase)
 | 
				
			||||||
 | 
					                ["list", $val] => ($val | str trim -c '[' | str trim -c ']')
 | 
				
			||||||
 | 
					                ["path", $val] => ($val | str replace 'BASE_DIR / "' $'"(pwd)/')
 | 
				
			||||||
 | 
					                [_, $val] => $val
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            $"($i.env_name)=($parsed_value)"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if ($vars | is-not-empty) {
 | 
				
			||||||
 | 
					        # on ajoute les nouvelles valeurs, 
 | 
				
			||||||
 | 
					        # en mettant une ligne vide de séparation avec les anciennes
 | 
				
			||||||
 | 
					        ["", ...$vars] | save --append .env
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    print $"($vars | length) values added to .env"
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
@@ -7,6 +7,7 @@ Certaines dépendances sont nécessaires niveau système :
 | 
				
			|||||||
- libjpeg
 | 
					- libjpeg
 | 
				
			||||||
- zlib1g-dev
 | 
					- zlib1g-dev
 | 
				
			||||||
- gettext
 | 
					- gettext
 | 
				
			||||||
 | 
					- redis
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Installer WSL
 | 
					### Installer WSL
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -65,8 +66,8 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
 | 
				
			|||||||
        
 | 
					        
 | 
				
			||||||
        ```bash
 | 
					        ```bash
 | 
				
			||||||
        sudo apt install curl build-essential libssl-dev \
 | 
					        sudo apt install curl build-essential libssl-dev \
 | 
				
			||||||
        libjpeg-dev zlib1g-dev npm libffi-dev pkg-config \
 | 
					            libjpeg-dev zlib1g-dev npm libffi-dev pkg-config \
 | 
				
			||||||
        gettext git
 | 
					            gettext git redis
 | 
				
			||||||
        curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
					        curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
				
			||||||
        ```
 | 
					        ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -75,7 +76,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
 | 
				
			|||||||
        ```bash
 | 
					        ```bash
 | 
				
			||||||
        sudo pacman -Syu  # on s'assure que les dépôts et le système sont à jour
 | 
					        sudo pacman -Syu  # on s'assure que les dépôts et le système sont à jour
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        sudo pacman -S uv gcc git gettext pkgconf npm
 | 
					        sudo pacman -S uv gcc git gettext pkgconf npm redis
 | 
				
			||||||
        ```
 | 
					        ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=== "macOS"
 | 
					=== "macOS"
 | 
				
			||||||
@@ -84,7 +85,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
 | 
				
			|||||||
    Il est également nécessaire d'avoir installé xcode
 | 
					    Il est également nécessaire d'avoir installé xcode
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    ```bash    
 | 
					    ```bash    
 | 
				
			||||||
    brew install git uv npm
 | 
					    brew install git uv npm redis
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # Pour bien configurer gettext
 | 
					    # Pour bien configurer gettext
 | 
				
			||||||
    brew link gettext # (suivez bien les instructions supplémentaires affichées)
 | 
					    brew link gettext # (suivez bien les instructions supplémentaires affichées)
 | 
				
			||||||
@@ -99,6 +100,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
 | 
				
			|||||||
    Python ne fait pas parti des dépendances puisqu'il est automatiquement
 | 
					    Python ne fait pas parti des dépendances puisqu'il est automatiquement
 | 
				
			||||||
    installé par uv.
 | 
					    installé par uv.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Finaliser l'installation
 | 
					## Finaliser l'installation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Clonez le projet (depuis votre console WSL, si vous utilisez WSL)
 | 
					Clonez le projet (depuis votre console WSL, si vous utilisez WSL)
 | 
				
			||||||
@@ -120,20 +122,24 @@ uv run ./manage.py install_xapian
 | 
				
			|||||||
    de texte à l'écran.
 | 
					    de texte à l'écran.
 | 
				
			||||||
    C'est normal, il ne faut pas avoir peur.
 | 
					    C'est normal, il ne faut pas avoir peur.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Maintenant que les dépendances sont installées, nous
 | 
					Une fois les dépendances installées, il faut encore
 | 
				
			||||||
allons créer la base de données, la remplir avec des données de test,
 | 
					mettre en place quelques éléments de configuration,
 | 
				
			||||||
et compiler les traductions.
 | 
					qui peuvent varier d'un environnement à l'autre.
 | 
				
			||||||
Cependant, avant de faire cela, il est nécessaire de modifier
 | 
					Ces variables sont stockées dans un fichier `.env`.
 | 
				
			||||||
la configuration pour signifier que nous sommes en mode développement.
 | 
					Pour le créer, vous pouvez copier le fichier `.env.example` :
 | 
				
			||||||
Pour cela, nous allons créer un fichier `sith/settings_custom.py`
 | 
					 | 
				
			||||||
et l'utiliser pour surcharger les settings de base.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					```bash
 | 
				
			||||||
echo "DEBUG=True" > sith/settings_custom.py
 | 
					cp .env.example .env
 | 
				
			||||||
echo 'SITH_URL = "localhost:8000"' >> sith/settings_custom.py
 | 
					 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Enfin, nous pouvons lancer les commandes suivantes :
 | 
					Les variables par défaut contenues dans le fichier `.env`
 | 
				
			||||||
 | 
					devraient convenir pour le développement, sans modification.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Maintenant que les dépendances sont installées
 | 
				
			||||||
 | 
					et la configuration remplie, nous allons pouvoir générer
 | 
				
			||||||
 | 
					des données utiles pendant le développement.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Pour cela, lancez les commandes suivantes :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					```bash
 | 
				
			||||||
# Prépare la base de données
 | 
					# Prépare la base de données
 | 
				
			||||||
@@ -165,12 +171,41 @@ uv run ./manage.py runserver
 | 
				
			|||||||
    [http://localhost:8000](http://localhost:8000) 
 | 
					    [http://localhost:8000](http://localhost:8000) 
 | 
				
			||||||
    ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
 | 
					    ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!!note
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Le serveur de développement se charge de lancer redis
 | 
				
			||||||
 | 
					    et les autres services nécessaires au bon fonctionnement du site.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
!!!tip
 | 
					!!!tip
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Vous trouverez également, à l'adresse
 | 
					    Vous trouverez également, à l'adresse
 | 
				
			||||||
    [http://localhost:8000/api/docs](http://localhost:8000/api/docs),
 | 
					    [http://localhost:8000/api/docs](http://localhost:8000/api/docs),
 | 
				
			||||||
    une interface swagger, avec toutes les routes de l'API.
 | 
					    une interface swagger, avec toutes les routes de l'API.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!! question "Pourquoi l'installation est aussi complexe ?"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Cette question nous a été posée de nombreuses fois par des personnes
 | 
				
			||||||
 | 
					    essayant d'installer le projet.
 | 
				
			||||||
 | 
					    Il y a en effet un certain nombre d'étapes à suivre,
 | 
				
			||||||
 | 
					    de paquets à installer et de commandes à exécuter.
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    Le processus d'installation peut donc sembler complexe.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    En réalité, il est difficile de faire plus simple.
 | 
				
			||||||
 | 
					    En effet, un site web a besoin de beaucoup de composants
 | 
				
			||||||
 | 
					    pour être développé : il lui faut au minimum
 | 
				
			||||||
 | 
					    une base de données, un cache, un bundler Javascript
 | 
				
			||||||
 | 
					    et un interpréteur pour le code du serveur.
 | 
				
			||||||
 | 
					    Pour nos besoin particuliers, nous utilisons également
 | 
				
			||||||
 | 
					    un moteur de recherche full-text.
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    Nous avons tenté au maximum de limiter le nombre de dépendances
 | 
				
			||||||
 | 
					    et de sélecionner les plus simples à installer.
 | 
				
			||||||
 | 
					    Cependant, il est impossible de retirer l'intégralité
 | 
				
			||||||
 | 
					    de la complexité du processus.
 | 
				
			||||||
 | 
					    Si vous rencontrez des difficulté lors de l'installation,
 | 
				
			||||||
 | 
					    n'hésitez pas à demander de l'aide.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Générer la documentation
 | 
					## Générer la documentation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
La documentation est automatiquement mise en ligne à chaque envoi de code sur GitHub.
 | 
					La documentation est automatiquement mise en ligne à chaque envoi de code sur GitHub.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,292 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## Les permissions
 | 
					## Objectifs du système de permissions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Le fonctionnement de l'AE ne permet pas d'utiliser le système de permissions
 | 
					Les permissions attendues sur le site sont relativement spécifiques.
 | 
				
			||||||
intégré à Django tel quel. Lors de la conception du Sith, ce qui paraissait le
 | 
					L'accès à une ressource peut se faire selon un certain nombre
 | 
				
			||||||
plus simple à l'époque était de concevoir un système maison afin de se calquer
 | 
					de paramètres différents :
 | 
				
			||||||
sur ce que faisait l'ancien site.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Protéger un modèle
 | 
					`L'état de la ressource`
 | 
				
			||||||
 | 
					:   Certaines ressources
 | 
				
			||||||
 | 
					    sont visibles par tous les cotisants (voire tous les utilisateurs),
 | 
				
			||||||
 | 
					    à condition qu'elles aient passé une étape de modération.
 | 
				
			||||||
 | 
					    La visibilité des ressources non-modérées nécessite des permissions
 | 
				
			||||||
 | 
					    supplémentaires.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
La gestion des permissions se fait directement par modèle.
 | 
					`L'appartenance à un groupe`
 | 
				
			||||||
Il existe trois niveaux de permission :
 | 
					:   Les groupes Root, Admin Com, Admin SAS, etc.
 | 
				
			||||||
 | 
					    sont associés à des jeux de permissions.
 | 
				
			||||||
 | 
					    Par exemple, les membres du groupe Admin SAS ont tous les droits sur
 | 
				
			||||||
 | 
					    les ressources liées au SAS : ils peuvent voir,
 | 
				
			||||||
 | 
					    créer, éditer, supprimer et éventuellement modérer
 | 
				
			||||||
 | 
					    des images, des albums, des identifications de personnes...
 | 
				
			||||||
 | 
					    Il en va de même avec les admins Com pour la communication,
 | 
				
			||||||
 | 
					    les admins pédagogie pour le guide des UEs et ainsi de suite.
 | 
				
			||||||
 | 
					    Quant aux membres du groupe Root, ils ont tous les droits
 | 
				
			||||||
 | 
					    sur toutes les ressources du site.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					`Le statut de la cotisation`
 | 
				
			||||||
 | 
					:   Les non-cotisants n'ont presque aucun
 | 
				
			||||||
 | 
					    droit sur les ressources du site (ils peuvent seulement en voir une poignée),
 | 
				
			||||||
 | 
					    les anciens cotisants peuvent voir un grand nombre de ressources
 | 
				
			||||||
 | 
					    et les cotisants actuels ont la plupart des droits qui ne sont
 | 
				
			||||||
 | 
					    pas liés à un club ou à l'administration du site.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					`L'appartenance à un club`
 | 
				
			||||||
 | 
					:   Être dans un club donne le droit
 | 
				
			||||||
 | 
					    de voir la plupart des ressources liées au club dans lequel ils
 | 
				
			||||||
 | 
					    sont ; être dans le bureau du club donne en outre des droits
 | 
				
			||||||
 | 
					    d'édition et de création sur ces ressources.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					`Être l'auteur ou le possesseur d'une ressource`
 | 
				
			||||||
 | 
					:   Certaines ressources, comme les nouvelles, 
 | 
				
			||||||
 | 
					    enregistrent l'utilisateur qui les a créées ;
 | 
				
			||||||
 | 
					    ce dernier a les droits de voir, de modifier et éventuellement
 | 
				
			||||||
 | 
					    de supprimer ses ressources, quand bien même
 | 
				
			||||||
 | 
					    elles ne seraient pas visibles pour les utilisateurs normaux
 | 
				
			||||||
 | 
					    (par exemple, parce qu'elles ne sont pas encore modérées.)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Le système de permissions inclus par défaut dans django
 | 
				
			||||||
 | 
					permet de modéliser aisément l'accès à des ressources au niveau
 | 
				
			||||||
 | 
					de la table.
 | 
				
			||||||
 | 
					Ainsi, il n'est pas compliqué de gérer les permissions liées
 | 
				
			||||||
 | 
					aux groupes d'administration.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Cependant, une surcouche est nécessaire dès lors que l'on veut
 | 
				
			||||||
 | 
					gérer les droits liés à une ligne en particulier
 | 
				
			||||||
 | 
					d'une table de la base de données.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Nous essayons le plus possible de nous tenir aux fonctionnalités
 | 
				
			||||||
 | 
					de django, sans pour autant hésiter à nous rabattre sur notre
 | 
				
			||||||
 | 
					propre surcouche dès lors que les permissions attendues
 | 
				
			||||||
 | 
					deviennent trop spécifiques pour être gérées avec juste django.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!!info "Un peu d'histoire"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Les permissions du site n'ont pas toujours été gérées
 | 
				
			||||||
 | 
					    avec un mélange de fonctionnalités de django et de notre
 | 
				
			||||||
 | 
					    propre code.
 | 
				
			||||||
 | 
					    Pendant très longtemps, seule la surcouche était utilisée,
 | 
				
			||||||
 | 
					    ce qui menait souvent à des vérifications de droits
 | 
				
			||||||
 | 
					    inefficaces et à une gestion complexe de certaines
 | 
				
			||||||
 | 
					    parties qui auraient pu être manipulées beaucoup plus simplement.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    En plus de ça, les permissions liées à la plupart
 | 
				
			||||||
 | 
					    des groupes se faisait de manière hardcodée :
 | 
				
			||||||
 | 
					    plutôt que d'associer un groupe à un jeu de permission
 | 
				
			||||||
 | 
					    et de faire une jointure en db sur les groupes de l'utilisateur
 | 
				
			||||||
 | 
					    ayant cette permissions,
 | 
				
			||||||
 | 
					    on conservait la clef primaire du groupe dans la config
 | 
				
			||||||
 | 
					    et on vérifiait en dur dans le code que l'utilisateur
 | 
				
			||||||
 | 
					    était un des groupes voulus.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ce système possédait le triple désavantage de prendre énormément
 | 
				
			||||||
 | 
					    de temps, d'être extrêmement limité (de fait, si tout est hardcodé,
 | 
				
			||||||
 | 
					    on est obligé d'avoir le moins de groupes possibles pour que ça reste
 | 
				
			||||||
 | 
					    gérable) et d'être désespérément dangereux (par exemple : fin novembre 2024,
 | 
				
			||||||
 | 
					    une erreur dans le code a donné les accès à la création des cotisations
 | 
				
			||||||
 | 
					    à tout le monde ; mi-octobre 2019, le calcul des permissions des etickets
 | 
				
			||||||
 | 
					    pouvait faire tomber le site, cf. 
 | 
				
			||||||
 | 
					    [ce topic du forum](https://ae.utbm.fr/forum/topic/17943/?page=1msg2277272))
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					## Accès à toutes les ressources d'une table
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Gérer ce genre d'accès (par exemple : voir toutes les nouvelles
 | 
				
			||||||
 | 
					ou pouvoir supprimer n'importe quelle photo)
 | 
				
			||||||
 | 
					est exactement le problème que le système de permissions de django résout.
 | 
				
			||||||
 | 
					Nous utilisons donc ce système dans ce genre de situations.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!!note
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Nous décrivons ci-dessous l'usage que nous faisons du système
 | 
				
			||||||
 | 
					    de permissions de django,
 | 
				
			||||||
 | 
					    mais la seule source d'information complète et pleinement fiable
 | 
				
			||||||
 | 
					    sur le fonctionnement réel de ce système est 
 | 
				
			||||||
 | 
					    [la documentation de django](https://docs.djangoproject.com/fr/stable/topics/auth/default/).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Permissions d'un modèle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Par défaut, django crée quatre permissions pour chaque table de la base de données :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `add_<nom de la table>` : créer un objet dans cette table
 | 
				
			||||||
 | 
					- `view_<nom de la table>` : voir le contenu de la table
 | 
				
			||||||
 | 
					- `change_<nom de la table>` : éditer des objets de la table
 | 
				
			||||||
 | 
					- `delete_<nom de la table>` : supprimer des objets de la table
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ces permissions sont créées au même moment que le modèle.
 | 
				
			||||||
 | 
					Si la table existe en base de données, ces permissions existent aussi.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Il est également possible de rajouter nos propres permissions,
 | 
				
			||||||
 | 
					directement dans les options Meta du modèle.
 | 
				
			||||||
 | 
					Par exemple, prenons le modèle suivant :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					from django.db import models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class News(models.Model):
 | 
				
			||||||
 | 
					    # ...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        permissions = [
 | 
				
			||||||
 | 
					            ("moderate_news", "Can moderate news"),
 | 
				
			||||||
 | 
					            ("view_unmoderated_news", "Can view non-moderated news"),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ce dernier aura les permissions : `view_news`, `add_news`, `change_news`,
 | 
				
			||||||
 | 
					`delete_news`, `moderate_news` et `view_unmoderated_news`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Utilisation des permissions d'un modèle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Pour vérifier qu'un utilisateur a une permission,
 | 
				
			||||||
 | 
					on utilise les fonctions suivantes :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `User.has_perm(perm)` : retourne `True` si l'utilisateur
 | 
				
			||||||
 | 
					  a la permission voulue, sinon `False`
 | 
				
			||||||
 | 
					- `User.has_perms([perm_a, perm_b, perm_c])` : retourne `True` si l'utilisateur
 | 
				
			||||||
 | 
					  a toutes les permissions voulues, sinon `False`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ces fonctions attendent un string suivant le format :
 | 
				
			||||||
 | 
					`<nom de l'application>.<nom de la permission>`.
 | 
				
			||||||
 | 
					Par exemple, la permission pour vérifier qu'un utilisateur
 | 
				
			||||||
 | 
					peut modérer une nouvelle sera : `com.moderate_news`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Ces fonctions sont utilisables aussi bien dans les templates Jinja
 | 
				
			||||||
 | 
					que dans le code Python :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=== "Jinja"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ```jinja
 | 
				
			||||||
 | 
					    {% if user.has_perm("com.moderate_news") %}
 | 
				
			||||||
 | 
					        <form method="post" action="{{ url("com:news_moderate", news_id=387) }}">
 | 
				
			||||||
 | 
					            <input type="submit" value="Modérer" />
 | 
				
			||||||
 | 
					        </form>
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=== "Python"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ```python
 | 
				
			||||||
 | 
					    from com.models import News
 | 
				
			||||||
 | 
					    from core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    user = User.objects.get(username="bibou")
 | 
				
			||||||
 | 
					    news = News.objects.get(id=387)
 | 
				
			||||||
 | 
					    if user.has_perm("com.moderate_news"):
 | 
				
			||||||
 | 
					        news.is_moderated = True
 | 
				
			||||||
 | 
					        news.save()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        raise PermissionDenied
 | 
				
			||||||
 | 
					    ```        
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Pour utiliser ce système de permissions dans une class-based view
 | 
				
			||||||
 | 
					(c'est-à-dire la plus grande partie de nos vues),
 | 
				
			||||||
 | 
					Django met à disposition `PermissionRequiredMixin`,
 | 
				
			||||||
 | 
					qui restreint l'accès à la vue aux utilisateurs ayant
 | 
				
			||||||
 | 
					la ou les permissions requises.
 | 
				
			||||||
 | 
					Pour les vues sous forme de fonction, il y a le décorateur
 | 
				
			||||||
 | 
					`permission_required`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=== "Class-Based View"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ```python
 | 
				
			||||||
 | 
					    from com.models import News
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
				
			||||||
 | 
					    from django.shortcuts import redirect
 | 
				
			||||||
 | 
					    from django.urls import reverse
 | 
				
			||||||
 | 
					    from django.views import View
 | 
				
			||||||
 | 
					    from django.views.generic.detail import SingleObjectMixin
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
 | 
				
			||||||
 | 
					        model = News
 | 
				
			||||||
 | 
					        pk_url_kwarg = "news_id"
 | 
				
			||||||
 | 
					        permission_required = "com.moderate_news"
 | 
				
			||||||
 | 
					        # On peut aussi fournir plusieurs permissions, par exemple :
 | 
				
			||||||
 | 
					        # permission_required = ["com.moderate_news", "com.delete_news"]
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					        def post(self, request, *args, **kwargs):
 | 
				
			||||||
 | 
					            # Si nous sommes ici, nous pouvons être certains que l'utilisateur
 | 
				
			||||||
 | 
					            # a la permission requise
 | 
				
			||||||
 | 
					            obj = self.get_object()
 | 
				
			||||||
 | 
					            obj.is_moderated = True
 | 
				
			||||||
 | 
					            obj.save()
 | 
				
			||||||
 | 
					            return redirect(reverse("com:news_list"))
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=== "Function-based view"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ```python
 | 
				
			||||||
 | 
					    from com.models import News
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    from django.contrib.auth.decorators import permission_required
 | 
				
			||||||
 | 
					    from django.shortcuts import get_object_or_404, redirect
 | 
				
			||||||
 | 
					    from django.urls import reverse
 | 
				
			||||||
 | 
					    from django.views.decorators.http import require_POST
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    @permission_required("com.moderate_news")
 | 
				
			||||||
 | 
					    @require_POST
 | 
				
			||||||
 | 
					    def moderate_news(request, news_id: int):
 | 
				
			||||||
 | 
					        # Si nous sommes ici, nous pouvons être certains que l'utilisateur
 | 
				
			||||||
 | 
					        # a la permission requise
 | 
				
			||||||
 | 
					        news = get_object_or_404(News, id=news_id)
 | 
				
			||||||
 | 
					        news.is_moderated = True
 | 
				
			||||||
 | 
					        news.save()
 | 
				
			||||||
 | 
					        return redirect(reverse("com:news_list"))
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Accès à des éléments en particulier
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Accès à l'auteur de la ressource
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Dans ce genre de cas, on peut identifier trois acteurs possibles :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- les administrateurs peuvent accéder à toutes les ressources,
 | 
				
			||||||
 | 
					  y compris non-modérées
 | 
				
			||||||
 | 
					- l'auteur d'une ressource non-modérée peut y accéder
 | 
				
			||||||
 | 
					- Les autres utilisateurs ne peuvent pas voir les ressources
 | 
				
			||||||
 | 
					  non-modérées dont ils ne sont pas l'auteur
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Dans ce genre de cas, on souhaite donc accorder l'accès aux
 | 
				
			||||||
 | 
					utilisateurs qui ont la permission globale, selon le système
 | 
				
			||||||
 | 
					décrit plus haut, ou bien à l'auteur de la ressource.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Pour cela, nous avons le mixin `PermissionOrAuthorRequired`.
 | 
				
			||||||
 | 
					Ce dernier va effectuer les mêmes vérifications que `PermissionRequiredMixin`
 | 
				
			||||||
 | 
					puis, si l'utilisateur n'a pas la permission requise, vérifier
 | 
				
			||||||
 | 
					s'il est l'auteur de la ressource.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					from com.models import News
 | 
				
			||||||
 | 
					from core.auth.mixins import PermissionOrAuthorRequiredMixin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.views.generic import UpdateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
 | 
				
			||||||
 | 
					    model = News
 | 
				
			||||||
 | 
					    pk_url_kwarg = "news_id"
 | 
				
			||||||
 | 
					    permission_required = "com.change_news"
 | 
				
			||||||
 | 
					    author_field = "author"  # (1)!
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Nom du champ du modèle utilisé comme clef étrangère vers l'auteur.
 | 
				
			||||||
 | 
					   Par exemple, ici, la permission sera accordée si
 | 
				
			||||||
 | 
					   l'utilisateur connecté correspond à l'utilisateur
 | 
				
			||||||
 | 
					   désigné par `News.author`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Accès en fonction de règles plus complexes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Tout ce que nous avons décrit précédemment permet de couvrir
 | 
				
			||||||
 | 
					la plupart des cas simples.
 | 
				
			||||||
 | 
					Cependant, il arrivera souvent que les permissions attendues soient
 | 
				
			||||||
 | 
					plus complexes.
 | 
				
			||||||
 | 
					Dans ce genre de cas, on rentre entièrement dans notre surcouche.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#### Implémentation dans les modèles
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					La gestion de ce type de permissions se fait directement par modèle.
 | 
				
			||||||
 | 
					Il en existe trois niveaux :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- Éditer des propriétés de l'objet
 | 
					- Éditer des propriétés de l'objet
 | 
				
			||||||
- Éditer certaines valeurs l'objet
 | 
					- Éditer certaines valeurs l'objet
 | 
				
			||||||
@@ -47,28 +324,43 @@ Voici un exemple d'implémentation de ce système :
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    from core.models import User, Group
 | 
					    from core.models import User, Group
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Utilisation de la protection par fonctions
 | 
					 | 
				
			||||||
    class Article(models.Model):
 | 
					    class Article(models.Model):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        title = models.CharField(_("title"), max_length=100)
 | 
					        title = models.CharField(_("title"), max_length=100)
 | 
				
			||||||
        content = models.TextField(_("content"))
 | 
					        content = models.TextField(_("content"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Donne ou non les droits d'édition des propriétés de l'objet
 | 
					        def is_owned_by(self, user):  # (1)!
 | 
				
			||||||
        # Un utilisateur dans le bureau AE aura tous les droits sur cet objet
 | 
					 | 
				
			||||||
        def is_owned_by(self, user):
 | 
					 | 
				
			||||||
            return user.is_board_member
 | 
					            return user.is_board_member
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Donne ou non les droits d'édition de l'objet
 | 
					        def can_be_edited_by(self, user):  # (2)!
 | 
				
			||||||
        # L'objet ne sera modifiable que par un utilisateur cotisant
 | 
					 | 
				
			||||||
        def can_be_edited_by(self, user):
 | 
					 | 
				
			||||||
            return user.is_subscribed
 | 
					            return user.is_subscribed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Donne ou non les droits de vue de l'objet
 | 
					        def can_be_viewed_by(self, user):  # (3)!
 | 
				
			||||||
        # Ici, l'objet n'est visible que par un utilisateur connecté
 | 
					 | 
				
			||||||
        def can_be_viewed_by(self, user):
 | 
					 | 
				
			||||||
            return not user.is_anonymous
 | 
					            return not user.is_anonymous
 | 
				
			||||||
    ```
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    1. Donne ou non les droits d'édition des propriétés de l'objet.
 | 
				
			||||||
 | 
					       Ici, un utilisateur dans le bureau AE aura tous les droits sur cet objet
 | 
				
			||||||
 | 
					    2. Donne ou non les droits d'édition de l'objet
 | 
				
			||||||
 | 
					       Ici, l'objet ne sera modifiable que par un utilisateur cotisant
 | 
				
			||||||
 | 
					    3. Donne ou non les droits de vue de l'objet
 | 
				
			||||||
 | 
					       Ici, l'objet n'est visible que par un utilisateur connecté
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    !!!note
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Dans cet exemple, nous utilisons des permissions très simples
 | 
				
			||||||
 | 
					        pour que vous puissiez constater le squelette de ce système,
 | 
				
			||||||
 | 
					        plutôt que la logique de validation dans ce cas particulier.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        En réalité, il serait ici beaucoup plus approprié de
 | 
				
			||||||
 | 
					        donner les permissions `com.delete_article` et
 | 
				
			||||||
 | 
					        `com.change_article_properties` (en créant ce dernier 
 | 
				
			||||||
 | 
					        s'il n'existe pas encore) au groupe du bureau AE, 
 | 
				
			||||||
 | 
					        de donner également la permission `com.change_article`
 | 
				
			||||||
 | 
					        au groupe `Cotisants` et enfin de restreindre l'accès 
 | 
				
			||||||
 | 
					        aux vues d'accès aux articles avec `LoginRequiredMixin`.
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=== "Avec les groupes de permission"
 | 
					=== "Avec les groupes de permission"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ```python
 | 
					    ```python
 | 
				
			||||||
@@ -83,15 +375,12 @@ Voici un exemple d'implémentation de ce système :
 | 
				
			|||||||
        content = models.TextField(_("content"))
 | 
					        content = models.TextField(_("content"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # relation one-to-many
 | 
					        # relation one-to-many
 | 
				
			||||||
        # Groupe possédant l'objet
 | 
					        owner_group = models.ForeignKey(  # (1)!
 | 
				
			||||||
        # Donne les droits d'édition des propriétés de l'objet
 | 
					 | 
				
			||||||
        owner_group = models.ForeignKey(
 | 
					 | 
				
			||||||
            Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID
 | 
					            Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        # relation many-to-many
 | 
					        # relation many-to-many
 | 
				
			||||||
        # Tous les groupes qui seront ajouté dans ce champ auront les droits d'édition de l'objet
 | 
					        edit_groups = models.ManyToManyField(  # (2)!
 | 
				
			||||||
        edit_groups = models.ManyToManyField(
 | 
					 | 
				
			||||||
            Group,
 | 
					            Group,
 | 
				
			||||||
            related_name="editable_articles",
 | 
					            related_name="editable_articles",
 | 
				
			||||||
            verbose_name=_("edit groups"),
 | 
					            verbose_name=_("edit groups"),
 | 
				
			||||||
@@ -99,8 +388,7 @@ Voici un exemple d'implémentation de ce système :
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
        # relation many-to-many
 | 
					        # relation many-to-many
 | 
				
			||||||
        # Tous les groupes qui seront ajouté dans ce champ auront les droits de vue de l'objet
 | 
					        view_groups = models.ManyToManyField(  # (3)!
 | 
				
			||||||
        view_groups = models.ManyToManyField(
 | 
					 | 
				
			||||||
            Group,
 | 
					            Group,
 | 
				
			||||||
            related_name="viewable_articles",
 | 
					            related_name="viewable_articles",
 | 
				
			||||||
            verbose_name=_("view groups"),
 | 
					            verbose_name=_("view groups"),
 | 
				
			||||||
@@ -108,18 +396,25 @@ Voici un exemple d'implémentation de ce système :
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
    ```
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Appliquer les permissions
 | 
					    1. Groupe possédant l'objet
 | 
				
			||||||
 | 
					       Donne les droits d'édition des propriétés de l'objet.
 | 
				
			||||||
 | 
					       Il ne peut y avoir qu'un seul groupe `owner` par objet.
 | 
				
			||||||
 | 
					    2. Tous les groupes ayant droit d'édition sur l'objet.
 | 
				
			||||||
 | 
					       Il peut y avoir autant de groupes d'édition que l'on veut par objet.
 | 
				
			||||||
 | 
					    3. Tous les groupes ayant droit de voir l'objet.
 | 
				
			||||||
 | 
					       Il peut y avoir autant de groupes de vue que l'on veut par objet.
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
#### Dans un template
 | 
					
 | 
				
			||||||
 | 
					#### Application dans les templates
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Il existe trois fonctions de base sur lesquelles 
 | 
					Il existe trois fonctions de base sur lesquelles 
 | 
				
			||||||
reposent les vérifications de permission. 
 | 
					reposent les vérifications de permission. 
 | 
				
			||||||
Elles sont disponibles dans le contexte par défaut du 
 | 
					Elles sont disponibles dans le contexte par défaut du 
 | 
				
			||||||
moteur de template et peuvent être utilisées à tout moment.
 | 
					moteur de template et peuvent être utilisées à tout moment.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- [can_edit_prop(obj, user)][core.views.can_edit_prop] : équivalent de `obj.is_owned_by(user)`
 | 
					- [can_edit_prop(obj, user)][core.auth.mixins.can_edit_prop] : équivalent de `obj.is_owned_by(user)`
 | 
				
			||||||
- [can_edit(obj, user)][core.views.can_edit] : équivalent de `obj.can_be_edited_by(user)`
 | 
					- [can_edit(obj, user)][core.auth.mixins.can_edit] : équivalent de `obj.can_be_edited_by(user)`
 | 
				
			||||||
- [can_view(obj, user)][core.views.can_view] : équivalent de `obj.can_be_viewed_by(user)`
 | 
					- [can_view(obj, user)][core.auth.mixins.can_view] : équivalent de `obj.can_be_viewed_by(user)`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Voici un exemple d'utilisation dans un template :
 | 
					Voici un exemple d'utilisation dans un template :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -130,7 +425,7 @@ Voici un exemple d'utilisation dans un template :
 | 
				
			|||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#### Dans une vue
 | 
					#### Application dans les vues
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Généralement, les vérifications de droits dans les templates
 | 
					Généralement, les vérifications de droits dans les templates
 | 
				
			||||||
se limitent aux urls à afficher puisqu'il 
 | 
					se limitent aux urls à afficher puisqu'il 
 | 
				
			||||||
@@ -138,7 +433,7 @@ ne faut normalement pas mettre de logique autre que d'affichage à l'intérieur
 | 
				
			|||||||
(en réalité, c'est un principe qu'on a beaucoup violé, mais promis on le fera plus).
 | 
					(en réalité, c'est un principe qu'on a beaucoup violé, mais promis on le fera plus).
 | 
				
			||||||
C'est donc habituellement au niveau des vues que cela a lieu.
 | 
					C'est donc habituellement au niveau des vues que cela a lieu.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Notre système s'appuie sur un système de mixin
 | 
					Pour cela, nous avons rajouté des mixins
 | 
				
			||||||
à hériter lors de la création d'une vue basée sur une classe.
 | 
					à hériter lors de la création d'une vue basée sur une classe.
 | 
				
			||||||
Ces mixins ne sont compatibles qu'avec les classes récupérant
 | 
					Ces mixins ne sont compatibles qu'avec les classes récupérant
 | 
				
			||||||
un objet ou une liste d'objet.
 | 
					un objet ou une liste d'objet.
 | 
				
			||||||
@@ -152,34 +447,60 @@ l'utilisateur recevra une liste vide d'objet.
 | 
				
			|||||||
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
 | 
					Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```python
 | 
					```python
 | 
				
			||||||
from django.views.generic import CreateView, ListView
 | 
					from django.views.generic import CreateView, DetailView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.views import CanViewMixin, CanCreateMixin
 | 
					from core.auth.mixins import CanViewMixin, CanCreateMixin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from com.models import WeekmailArticle
 | 
					from com.models import WeekmailArticle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Il est important de mettre le mixin avant la classe héritée de Django
 | 
					# Il est important de mettre le mixin avant la classe héritée de Django
 | 
				
			||||||
# L'héritage multiple se fait de droite à gauche et les mixins ont besoin
 | 
					# L'héritage multiple se fait de droite à gauche et les mixins ont besoin
 | 
				
			||||||
# d'une classe de base pour fonctionner correctement.
 | 
					# d'une classe de base pour fonctionner correctement.
 | 
				
			||||||
class ArticlesListView(CanViewMixin, ListView):
 | 
					class ArticlesDetailView(CanViewMixin, DetailView):
 | 
				
			||||||
  model = WeekmailArticle
 | 
					    model = WeekmailArticle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Même chose pour une vue de création de l'objet Article
 | 
					# Même chose pour une vue de création de l'objet Article
 | 
				
			||||||
class ArticlesCreateView(CanCreateMixin, CreateView):
 | 
					class ArticlesCreateView(CanCreateMixin, CreateView):
 | 
				
			||||||
  model = WeekmailArticle
 | 
					    model = WeekmailArticle
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Les mixins suivants sont implémentés :
 | 
					Les mixins suivants sont implémentés :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- [CanCreateMixin][core.views.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
 | 
					- [CanCreateMixin][core.auth.mixins.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
 | 
				
			||||||
- [CanEditPropMixin][core.views.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
 | 
					  Ce mixin existe, mais est déprécié et ne doit plus être utilisé !
 | 
				
			||||||
- [CanEditMixin][core.views.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
 | 
					- [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
 | 
				
			||||||
- [CanViewMixin][core.views.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
 | 
					- [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
 | 
				
			||||||
- [UserIsRootMixin][core.views.UserIsRootMixin] : L'utilisateur a-t-il les droit root ?
 | 
					- [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
 | 
				
			||||||
- [FormerSubscriberMixin][core.views.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
 | 
					- [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
 | 
				
			||||||
- [UserIsLoggedMixin][core.views.UserIsLoggedMixin] : L'utilisateur est-il connecté ?
 | 
					
 | 
				
			||||||
  (à éviter ; préférez `LoginRequiredMixin`, fourni par Django)
 | 
					!!!danger "CanCreateMixin"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    L'usage de `CanCreateMixin` est dangereux et ne doit en aucun cas être
 | 
				
			||||||
 | 
					    étendu.
 | 
				
			||||||
 | 
					    La façon dont ce mixin marche est qu'il valide le formulaire
 | 
				
			||||||
 | 
					    de création et crée l'objet sans le persister en base de données, puis
 | 
				
			||||||
 | 
					    vérifie les droits sur cet objet non-persisté.
 | 
				
			||||||
 | 
					    Le danger de ce système vient de multiples raisons :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - Les vérifications se faisant sur un objet non persisté,
 | 
				
			||||||
 | 
					      l'utilisation de mécanismes nécessitant une persistance préalable
 | 
				
			||||||
 | 
					      peut mener à des comportements indésirés, voire à des erreurs.
 | 
				
			||||||
 | 
					    - Les développeurs de django ayant tendance à restreindre progressivement
 | 
				
			||||||
 | 
					      les actions qui peuvent être faites sur des objets non-persistés,
 | 
				
			||||||
 | 
					      les mises-à-jour de django deviennent plus compliquées.
 | 
				
			||||||
 | 
					    - La vérification des droits ne se fait que dans les requêtes POST,
 | 
				
			||||||
 | 
					      à la toute fin de la requête.
 | 
				
			||||||
 | 
					      Tout ce qui arrive avant n'est absolument pas protégé.
 | 
				
			||||||
 | 
					      Toute opération (même les suppressions et les créations) qui ont
 | 
				
			||||||
 | 
					      lieu avant la persistance de l'objet seront appliquées,
 | 
				
			||||||
 | 
					      même sans permission.
 | 
				
			||||||
 | 
					    - Si un développeur du site fait l'erreur de surcharger
 | 
				
			||||||
 | 
					      la méthode `form_valid` (ce qui est plutôt courant,
 | 
				
			||||||
 | 
					      lorsqu'on veut accomplir certaines actions 
 | 
				
			||||||
 | 
					      quand un formulaire est valide), on peut se retrouver
 | 
				
			||||||
 | 
					      dans une situation où l'objet est persisté sans aucune protection.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
!!!danger "Performance"
 | 
					!!!danger "Performance"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -197,6 +518,76 @@ Les mixins suivants sont implémentés :
 | 
				
			|||||||
    Mais sur les `ListView`, on peut arriver à des temps
 | 
					    Mais sur les `ListView`, on peut arriver à des temps
 | 
				
			||||||
    de réponse extrêmement élevés.
 | 
					    de réponse extrêmement élevés.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Filtrage des querysets
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Récupérer tous les objets d'un queryset et vérifier pour chacun que
 | 
				
			||||||
 | 
					l'utilisateur a le droit de les voir peut-être excessivement
 | 
				
			||||||
 | 
					coûteux en ressources
 | 
				
			||||||
 | 
					(cf. l'encart ci-dessus).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Lorsqu'il est nécessaire de récupérer un certain nombre
 | 
				
			||||||
 | 
					d'objets depuis la base de données, il est donc préférable
 | 
				
			||||||
 | 
					de filtrer directement depuis le queryset.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Pour cela, certains modèles, tels que [Picture][sas.models.Picture] 
 | 
				
			||||||
 | 
					peuvent être filtrés avec la méthode de queryset `viewable_by`.
 | 
				
			||||||
 | 
					Cette dernière s'utilise comme n'importe quelle autre méthode
 | 
				
			||||||
 | 
					de queryset :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					from sas.models import Picture
 | 
				
			||||||
 | 
					from core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					user = User.objects.get(username="bibou")
 | 
				
			||||||
 | 
					pictures = Picture.objects.viewable_by(user)
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Le résultat de la requête contiendra uniquement des éléments
 | 
				
			||||||
 | 
					que l'utilisateur sélectionné a effectivement le droit de voir.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Si vous désirez utiliser cette méthode sur un modèle
 | 
				
			||||||
 | 
					qui ne la possède pas, il est relativement facile de l'écrire :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```python
 | 
				
			||||||
 | 
					from typing import Self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NewsQuerySet(models.QuerySet):  # (1)!
 | 
				
			||||||
 | 
					    def viewable_by(self, user: User) -> Self:
 | 
				
			||||||
 | 
					        if user.has_perm("com.view_unmoderated_news"):
 | 
				
			||||||
 | 
					            # si l'utilisateur peut tout voir, on retourne tout
 | 
				
			||||||
 | 
					            return self
 | 
				
			||||||
 | 
					        # sinon, on retourne les nouvelles modérées ou dont l'utilisateur
 | 
				
			||||||
 | 
					        # est l'auteur
 | 
				
			||||||
 | 
					        return self.filter(
 | 
				
			||||||
 | 
					            models.Q(is_moderated=True)
 | 
				
			||||||
 | 
					            | models.Q(author=user)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class News(models.Model):
 | 
				
			||||||
 | 
					    is_moderated = models.BooleanField(default=False)
 | 
				
			||||||
 | 
					    author = models.ForeignKey(User, on_delete=models.PROTECT)
 | 
				
			||||||
 | 
					    # ...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = NewsQuerySet.as_manager()  # (2)!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        permissions = [("view_unmoderated_news", "Can view non moderated news")]
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. On crée un `QuerySet` maison, dans lequel on définit la méthode `viewable_by`
 | 
				
			||||||
 | 
					2. Puis, on attache ce `QuerySet` à notre modèle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					!!!note
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Pour plus d'informations sur la création de `QuerySet` personnalisés, voir
 | 
				
			||||||
 | 
					    [la documentation de django](https://docs.djangoproject.com/fr/stable/topics/db/managers/)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## API
 | 
					## API
 | 
				
			||||||
 | 
					
 | 
				
			||||||
L'API utilise son propre système de permissions.
 | 
					L'API utilise son propre système de permissions.
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user