mirror of
https://github.com/ae-utbm/sith.git
synced 2026-07-17 12:06:57 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac8a79468e | ||
|
|
448e67738e | ||
|
|
847511d27f | ||
|
|
a75d5e06b6 | ||
|
|
1c277365ed | ||
|
|
e7ef3d0020 | ||
|
|
5c87bcf4b5 | ||
|
|
92b83071ad | ||
|
|
7ad469ff24 | ||
|
|
c076b80e40 | ||
|
|
dcdfb086ed | ||
|
|
5625268d6b | ||
|
|
648b6b6af0 | ||
|
|
73ae7666c1 | ||
|
|
defeef62ea | ||
|
|
859d724c34 | ||
|
|
307f89ce90 | ||
|
|
44f8655b70 | ||
|
|
8f15facd8f | ||
|
|
1af1712efc | ||
|
|
0ba001ccda | ||
|
|
feca466dbe | ||
|
|
519a7758c5 | ||
|
|
caa2bf66be | ||
|
|
998efc7c6b | ||
|
|
867362fb51 | ||
|
|
d41a3a524a | ||
|
|
39bbbc8878 | ||
|
|
5e553d91a8 | ||
|
|
f6f31af975 | ||
|
|
1957aa1fdb | ||
|
|
d73b7de903 | ||
|
|
4c5149aec9 | ||
|
|
89afdba055 | ||
|
|
e9a55c6f3a | ||
|
|
922c940fd5 | ||
|
|
29c0d0be28 | ||
|
|
b1ab21aff7 | ||
|
|
8ee385e1ae | ||
|
|
d0a80a3ec8 | ||
|
|
bcc44727df | ||
|
|
d25bcec9ba | ||
|
|
f3e78f229a | ||
|
|
00fb8a719f | ||
|
|
c4af372973 | ||
|
|
d119bfa0c6 | ||
|
|
dc2e7ff468 | ||
|
|
960657404b | ||
|
|
cd9cd81e8b | ||
|
|
733bd49a42 | ||
|
|
eb7f5def6e | ||
|
|
ba618aa3cd | ||
|
|
1456bc8044 | ||
|
|
177002b8b8 | ||
|
|
e629b36465 | ||
|
|
30a3911fa1 | ||
|
|
7c9ba29db1 | ||
|
|
cf31182429 | ||
|
|
29cacf8efc | ||
|
|
1e592e657f | ||
|
|
fb1790020b | ||
|
|
3cf142f3f1 | ||
|
|
222b0d16a7 | ||
|
|
074ebcb011 | ||
|
|
a26e06216e | ||
|
|
78c541dd36 | ||
|
|
b4d76c4f85 | ||
|
|
b5a2ec78df | ||
|
|
8022589902 | ||
|
|
6ae73a28b4 | ||
|
|
7f415c6a6c | ||
|
|
dd4887ead4 | ||
|
|
a8b6a2e43b | ||
|
|
f90cb5b91c | ||
|
|
d604147a93 | ||
|
|
3f2908eb8d | ||
|
|
e811aeaecd | ||
|
|
549a778be0 | ||
|
|
5c42da273b | ||
|
|
b8e0294df6 | ||
|
|
78b24dc1e7 | ||
|
|
ebf0196bef | ||
|
|
362b9eea06 | ||
|
|
3b3e33ed80 | ||
|
|
649190debe | ||
|
|
50c880719a | ||
|
|
99d85e0361 | ||
|
|
cff0dd2cf6 | ||
|
|
5238e2e2d6 | ||
|
|
4375fdf150 | ||
|
|
a10773bb37 | ||
|
|
ce0ddcd184 | ||
|
|
6cec2e74d0 | ||
|
|
2228a3f961 | ||
|
|
4b369b73a7 | ||
|
|
73f422db23 | ||
|
|
0aed36c8d9 | ||
|
|
e2c17175f5 | ||
|
|
59b4c4e73c | ||
|
|
b8623eed11 | ||
|
|
ed791dc544 | ||
|
|
18e8088cf3
|
||
|
|
5ef7fd0294 | ||
|
|
afa3ea4f2c | ||
|
|
c38fe7f9ae | ||
|
|
c6c8781511 | ||
|
|
30bd4fd3ea | ||
|
|
019ab95773 | ||
|
|
c83ab7c2c1 | ||
|
|
23dc7702c4 | ||
|
|
def4f80ec0 | ||
|
|
a5a31b6a4d | ||
|
|
eaf90efd5c | ||
|
|
4d1019c955 | ||
|
|
52f112a041 | ||
|
|
e89be22c34 | ||
|
|
fc37cbb454 | ||
|
|
e667058fe6 | ||
|
|
ffa4f82c7d | ||
|
|
307f1fb1fe | ||
|
|
b6522971b2 | ||
|
|
66f826625e | ||
|
|
a74f0dde39 | ||
|
|
c7160727ce | ||
|
|
31f36506a4 | ||
|
|
3d8a832aca | ||
|
|
07b08180d5 | ||
|
|
bfd147fd3c | ||
|
|
b784fd7ac8 | ||
|
|
204e4e97bd | ||
|
|
57a422de8c | ||
|
|
75f3094dd5 | ||
|
|
68aa4515f9 | ||
|
|
899fc90821 | ||
|
|
40a67f8c08 | ||
|
|
51c8e1e3dc | ||
|
|
d3094a850f | ||
|
|
9fdd5565a1 | ||
|
|
f0ac35370e | ||
|
|
54d261142d | ||
|
|
8876c64f54 | ||
|
|
a40c43204a | ||
|
|
44e8ab4fb9 | ||
|
|
c9caa3f324 | ||
|
|
aaaaeb204f | ||
|
|
fa190f6909 | ||
|
|
898ce48cc8 | ||
|
|
74d8a374b9 | ||
|
|
11d9e912e1 | ||
|
|
5cf54729ae | ||
|
|
387e5fe0a3 | ||
|
|
c371a5cf7a | ||
|
|
07eae232d0 | ||
|
|
e4fa9c3c4a | ||
|
|
5551fdc953
|
||
|
|
2d86ba67f4
|
||
|
|
deb83ec08f | ||
|
|
94cda48508 | ||
|
|
7bd2f1da96 | ||
|
|
e2d2eb7470 | ||
|
|
a18f316088 | ||
|
|
fee1bbc5a5 | ||
|
|
cb1a330983
|
||
|
|
1cfeeefa56 | ||
|
|
0308d1c887 | ||
|
|
9abd16c8f6 | ||
|
|
0405ef424d
|
||
|
|
b79b7cbcf5 | ||
|
|
2c259de22c | ||
|
|
38f2b6aa7b | ||
|
|
37345f1bc4 | ||
|
|
151d17aca1 | ||
|
|
d123e5e35b | ||
|
|
00f7afb937 | ||
|
|
2dbf4cff05 | ||
|
|
f88c061b02 | ||
|
|
4bd248f827 | ||
|
|
2aa6eed2fc | ||
|
|
7fec05820c | ||
|
|
381f1ac829 | ||
|
|
22e6c09c36 | ||
|
|
399a3813f0 | ||
|
|
441a016025 | ||
|
|
060dde78e7 | ||
|
|
f19b3056ef | ||
|
|
200f71a762 | ||
|
|
facfa1be89 | ||
|
|
4b9a953a20 | ||
|
|
5c17337595 | ||
|
|
da621f6d7e | ||
|
|
6b2b4d347f | ||
|
|
26483825a1 | ||
|
|
9ef592849c | ||
|
|
c329fb1d0e | ||
|
|
ef611ce1a7 | ||
|
|
fb23d24db7 | ||
|
|
16ca6c3ead | ||
|
|
f8f85c2778 | ||
|
|
cf40e941d4 | ||
|
|
adeadeaf70 | ||
|
|
40fce1609d | ||
|
|
0f02d55318 | ||
|
|
965309d76c | ||
|
|
26c6ab4a9f | ||
|
|
2613ede59d | ||
|
|
6634bd987d | ||
|
|
1c6d7f435a | ||
|
|
f9c5297473 | ||
|
|
52117b5a24 | ||
|
|
ae72a2e00f | ||
|
|
fdf89ea716 | ||
|
|
3954f2f170 | ||
|
|
d36d672d0b | ||
|
|
da3602329c | ||
|
|
8b18999514
|
||
|
|
1d525ca6d4 | ||
|
|
4dea60ac66
|
||
|
|
30df859039 | ||
|
|
9c3f846f98
|
||
|
|
64ebe30f5e | ||
|
|
744ea20c33
|
||
|
|
3e0a1c7334
|
||
|
|
ef036c135c
|
||
|
|
366d6c7f03 | ||
|
|
2151bbf4c7 | ||
|
|
64b3acff07
|
||
|
|
6ef8b6b159
|
||
|
|
39dee782cc
|
||
|
|
bda65f39af | ||
|
|
dfe884484d | ||
|
|
a0cce91bd5
|
||
|
|
0f00c91b59 | ||
|
|
b5d8db0187 | ||
|
|
26178f10c5 | ||
|
|
842ae5615d | ||
|
|
5cd748e313 | ||
|
|
2944804074 | ||
|
|
fcce34fde5 | ||
|
|
a213bc058d
|
||
|
|
71bfdf68b7
|
||
|
|
2a9bb46e97
|
||
|
|
efdf71d69e
|
||
|
|
3bc4f1300e
|
||
|
|
182cdbe590 | ||
|
|
ac33a5e6b2 | ||
|
|
068bb9ab83 | ||
|
|
f9910c3360 | ||
|
|
f0f8cc5604 | ||
|
|
2a8e810ad0 | ||
|
|
739a1bba47 | ||
|
|
180852a598 | ||
|
|
c3989a0016 | ||
|
|
435c8f9612 | ||
|
|
3d7f57b8da | ||
|
|
ffa0b94408 | ||
|
|
22a1f4ba07 | ||
|
|
76396cdeb0 | ||
|
|
1c0b89bfc7 | ||
|
|
d374ea9651 | ||
|
|
10a4e71b7a | ||
|
|
f1a60e589a | ||
|
|
00acda7ba3 | ||
|
|
1686a9da87 | ||
|
|
83255945c4 | ||
|
|
b4a6b6961b | ||
|
|
0f0702825e | ||
|
|
b74b1ac691 | ||
|
|
33d4a99a2c | ||
|
|
c154b311c3 | ||
|
|
fb8da93c68 | ||
|
|
1845a7cbcf | ||
|
|
f17f17d8de | ||
|
|
7bb3d064ee | ||
|
|
4f84ec09d7 | ||
|
|
7e649b40c5 | ||
|
|
296feb6e32 | ||
|
|
30663d87a4 | ||
|
|
b5ff9b4c13 | ||
|
|
e2f6671ad0 | ||
|
|
9a67926a49 | ||
|
|
78c373f84e | ||
|
|
a7c8b318bd | ||
|
|
1701ab5f33 | ||
|
|
09a98db786 | ||
|
|
84ed180c1e | ||
|
|
52759764a1 | ||
|
|
be1563f46f | ||
|
|
5d3d44ec67 | ||
|
|
25e19339ff | ||
|
|
10c4a921db | ||
|
|
74bf462e90 | ||
|
|
8e4d367522 | ||
|
|
f713903589 | ||
|
|
9506c8688f | ||
|
|
f3f470ec6c | ||
|
|
ced524587f | ||
|
|
5f01f973de | ||
|
|
6a6a7e949f | ||
|
|
4e73f103d8 | ||
|
|
b03346c733 | ||
|
|
7be1d1cc63 | ||
|
|
71ed7cdf7d | ||
|
|
43768171a1 | ||
|
|
0eccb4a5b5 | ||
|
|
e7584c8c83 | ||
|
|
ac06de4f55 | ||
|
|
e2fca3e6d2 | ||
|
|
2138783bde | ||
|
|
4391f63de8 | ||
|
|
8b7eb6edf9
|
||
|
|
f8cda3a31d | ||
|
|
433d29fcdb
|
||
|
|
514b8bbec7 | ||
|
|
84033f37cf | ||
|
|
e71f76ea91 | ||
|
|
530475c4ee | ||
|
|
e992bebd68 | ||
|
|
8f1c786aa2 | ||
|
|
c5ae81aae7 | ||
|
|
252acc64c1 | ||
|
|
0d2430a5d4 | ||
|
|
b6f77dea97 | ||
|
|
df2e65a991 | ||
|
|
de776045a8 | ||
|
|
367ea703ce | ||
|
|
bdcb802da8 | ||
|
|
4e4b5a39f7 | ||
|
|
51534629ed | ||
|
|
c042c8e8a3
|
||
|
|
5af894060a | ||
|
|
679b8dac1c | ||
|
|
e9eb3dc17d
|
||
|
|
8c6f7c82c9 | ||
|
|
6ec1834540
|
||
|
|
086a61f493 | ||
|
|
53a3dc0060 | ||
|
|
775a3282dc | ||
|
|
32570ee03d | ||
|
|
2fa3597722 | ||
|
|
d484971dad
|
||
|
|
f24e39ccb7
|
||
|
|
3a57439d6e | ||
|
|
fbe5c741d1 | ||
|
|
749cd067da
|
||
|
|
12b098feac | ||
|
|
1abfbeb76c | ||
|
|
0fb86e5d77 | ||
|
|
523e0ff0ee | ||
|
|
a68f16ba9d | ||
|
|
1a99f4096e | ||
|
|
559a904e0d | ||
|
|
fca6a58c5e | ||
|
|
39c3e11d88 | ||
|
|
d3edcaff14 | ||
|
|
8c127a96f7 | ||
|
|
55d6e2bbec | ||
|
|
e9fbac8264 | ||
|
|
1911f2e6dd | ||
|
|
77bdc8dcb5 | ||
|
|
00acdcd1a5 | ||
|
|
aa77cfd1c8 | ||
|
|
0d4b77ba1c | ||
|
|
5271783e88 | ||
|
|
4ff4d179a1 | ||
|
|
7cbb3a2c5d | ||
|
|
a0768d6d7f | ||
|
|
f55627a292 | ||
|
|
4f802ac56e | ||
|
|
16a6e07d4b | ||
|
|
33d6300131 | ||
|
|
6709befb1f | ||
|
|
ddfb88ca2a | ||
|
|
acdb9660f6 | ||
|
|
b60bd3a42b | ||
|
|
0c046b6164 | ||
|
|
c588e5117d | ||
|
|
ad87617018 | ||
|
|
56c2c2b70e | ||
|
|
78fe4e52ca | ||
|
|
2a5893aa79 | ||
|
|
0a4d21611e | ||
|
|
7373e3d9de | ||
|
|
3f4a41ba42 | ||
|
|
449abbb17e | ||
|
|
9862e763ad | ||
|
|
32e1f09d46 | ||
|
|
f359fab6b4 | ||
|
|
0b53db7a95 | ||
|
|
d325b19383 | ||
|
|
33cc9588b0 | ||
|
|
5f0d7c07ce | ||
|
|
17421e5cc9 | ||
|
|
e00a64252e | ||
|
|
926e5ae45c | ||
|
|
a27d8d0755 | ||
|
|
433fea1855 | ||
|
|
c0ed5bd393 | ||
|
|
ede15623df | ||
|
|
b9aa07646a | ||
|
|
3c79bd4d01 | ||
|
|
8819abe27c | ||
|
|
30e76a5e39 | ||
|
|
d50bb0d9b1 | ||
|
|
6c5b348a0a | ||
|
|
d0340603a2 | ||
|
|
2d60ae2ed8 | ||
|
|
80dbe7f742 | ||
|
|
a571bda766 | ||
|
|
04702335e2 | ||
|
|
c942ff6aec | ||
|
|
164e8c7a53 | ||
|
|
7042cc41f0 | ||
|
|
992b6d6b79 | ||
|
|
710b4aa942 | ||
|
|
5fee2e4720 |
@@ -12,7 +12,7 @@ runs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install apt packages
|
- name: Install apt packages
|
||||||
if: ${{ inputs.full == 'true' }}
|
if: ${{ inputs.full == 'true' }}
|
||||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||||
with:
|
with:
|
||||||
packages: gettext
|
packages: gettext
|
||||||
version: 1.0 # increment to reset cache
|
version: 1.0 # increment to reset cache
|
||||||
@@ -23,26 +23,29 @@ runs:
|
|||||||
with:
|
with:
|
||||||
redis-version: "7.x"
|
redis-version: "7.x"
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
version: "0.5.14"
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: "uv.lock"
|
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v8.1.0
|
||||||
|
with:
|
||||||
|
version: "0.11.8"
|
||||||
|
enable-cache: false
|
||||||
|
cache-dependency-glob: "uv.lock"
|
||||||
|
|
||||||
- name: Restore cached virtualenv
|
- name: Restore cached virtualenv
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
|
||||||
path: .venv
|
path: .venv
|
||||||
|
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
||||||
|
uv-${{ runner.os }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync
|
run: uv sync --locked
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Xapian
|
- name: Install Xapian
|
||||||
@@ -50,15 +53,6 @@ runs:
|
|||||||
run: uv run ./manage.py install_xapian
|
run: uv run ./manage.py install_xapian
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# compiling xapian accounts for almost the entirety of the virtualenv setup,
|
|
||||||
# so we save the virtual environment only on workflows where it has been installed
|
|
||||||
- name: Save cached virtualenv
|
|
||||||
if: ${{ inputs.full == 'true' }}
|
|
||||||
uses: actions/cache/save@v4
|
|
||||||
with:
|
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
|
||||||
path: .venv
|
|
||||||
|
|
||||||
- name: Compile gettext messages
|
- name: Compile gettext messages
|
||||||
if: ${{ inputs.full == 'true' }}
|
if: ${{ inputs.full == 'true' }}
|
||||||
run: uv run ./manage.py compilemessages
|
run: uv run ./manage.py compilemessages
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ jobs:
|
|||||||
name: Launch pre-commits checks (ruff)
|
name: Launch pre-commits checks (ruff)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
- uses: pre-commit/action@v3.0.1
|
- uses: pre-commit/action@v3.0.1
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
pytest-mark: [not slow]
|
pytest-mark: [not slow]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
with:
|
with:
|
||||||
full: true
|
full: true
|
||||||
@@ -49,7 +49,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@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: coverage-report-${{ matrix.pytest-mark }}
|
name: coverage-report-${{ matrix.pytest-mark }}
|
||||||
path: coverage_report
|
path: coverage_report
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: SSH Remote Commands
|
- name: SSH Remote Commands
|
||||||
uses: appleboy/ssh-action@v1.1.0
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
with:
|
with:
|
||||||
# Proxy
|
# Proxy
|
||||||
proxy_host : ${{secrets.PROXY_HOST}}
|
proxy_host : ${{secrets.PROXY_HOST}}
|
||||||
@@ -29,8 +29,6 @@ jobs:
|
|||||||
username : ${{secrets.USER}}
|
username : ${{secrets.USER}}
|
||||||
key: ${{secrets.KEY}}
|
key: ${{secrets.KEY}}
|
||||||
|
|
||||||
script_stop: true
|
|
||||||
|
|
||||||
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
||||||
script: |
|
script: |
|
||||||
cd ${{secrets.SITH_PATH}}
|
cd ${{secrets.SITH_PATH}}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||||
- uses: actions/cache@v3
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
key: mkdocs-material-${{ env.cache_id }}
|
key: mkdocs-material-${{ env.cache_id }}
|
||||||
path: .cache
|
path: .cache
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: SSH Remote Commands
|
- name: SSH Remote Commands
|
||||||
uses: appleboy/ssh-action@v1.1.0
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
with:
|
with:
|
||||||
# Proxy
|
# Proxy
|
||||||
proxy_host : ${{secrets.PROXY_HOST}}
|
proxy_host : ${{secrets.PROXY_HOST}}
|
||||||
@@ -28,8 +28,6 @@ jobs:
|
|||||||
username : ${{secrets.USER}}
|
username : ${{secrets.USER}}
|
||||||
key: ${{secrets.KEY}}
|
key: ${{secrets.KEY}}
|
||||||
|
|
||||||
script_stop: true
|
|
||||||
|
|
||||||
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
||||||
script: |
|
script: |
|
||||||
cd ${{secrets.SITH_PATH}}
|
cd ${{secrets.SITH_PATH}}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ node_modules/
|
|||||||
# compiled documentation
|
# compiled documentation
|
||||||
site/
|
site/
|
||||||
|
|
||||||
|
# rollup-bundle-visualizer report
|
||||||
|
.bundle-size-report.html
|
||||||
|
|
||||||
### Redis ###
|
### Redis ###
|
||||||
|
|
||||||
# Ignore redis binary dump (dump.rdb) files
|
# Ignore redis binary dump (dump.rdb) files
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.14.4
|
rev: v0.15.19
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check # just check the code, and print the errors
|
- id: ruff-check # just check the code, and print the errors
|
||||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||||
@@ -12,9 +12,9 @@ repos:
|
|||||||
rev: v0.6.1
|
rev: v0.6.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: biome-check
|
- id: biome-check
|
||||||
additional_dependencies: ["@biomejs/biome@1.9.4"]
|
additional_dependencies: ["@biomejs/biome@2.5.1"]
|
||||||
- repo: https://github.com/rtts/djhtml
|
- repo: https://github.com/rtts/djhtml
|
||||||
rev: 3.0.10
|
rev: 3.0.11
|
||||||
hooks:
|
hooks:
|
||||||
- id: djhtml
|
- id: djhtml
|
||||||
name: format templates
|
name: format templates
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ from django.utils.crypto import constant_time_compare
|
|||||||
|
|
||||||
class Sha512ApiKeyHasher(BasePasswordHasher):
|
class Sha512ApiKeyHasher(BasePasswordHasher):
|
||||||
"""
|
"""
|
||||||
An API key hasher using the sha256 algorithm.
|
An API key hasher using the sha512 algorithm.
|
||||||
|
|
||||||
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
|
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
|
||||||
It is insecure for use in hashing passwords, but is safe for hashing
|
It is insecure for use in hashing passwords, but is safe for hashing
|
||||||
|
|||||||
+2
-7
@@ -46,7 +46,7 @@ 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
|
||||||
|
|
||||||
from counter.models import Counter
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
class IsInGroup(BasePermission):
|
class IsInGroup(BasePermission):
|
||||||
@@ -186,12 +186,7 @@ class IsLoggedInCounter(BasePermission):
|
|||||||
"""Check that a user is logged in a counter."""
|
"""Check that a user is logged in a counter."""
|
||||||
|
|
||||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||||
if "/counter/" not in request.META.get("HTTP_REFERER", ""):
|
return is_logged_in_counter(request)
|
||||||
return False
|
|
||||||
token = request.session.get("counter_token")
|
|
||||||
if not token:
|
|
||||||
return False
|
|
||||||
return Counter.objects.filter(token=token).exists()
|
|
||||||
|
|
||||||
|
|
||||||
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
|
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
|
||||||
|
|||||||
+20
-6
@@ -7,23 +7,37 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"ignore": ["*.min.*", "staticfiles/generated"]
|
"includes": ["**/static/**", "vite.config.mts"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"lineWidth": 88
|
"lineWidth": 88
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"all": true
|
"preset": "recommended",
|
||||||
|
"style": {
|
||||||
|
"useNamingConvention": "error"
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"noNamespaceImport": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noConsole": {
|
||||||
|
"level": "error",
|
||||||
|
"options": { "allow": ["error", "warn"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "error",
|
||||||
|
"noUndeclaredVariables": "error",
|
||||||
|
"noUndeclaredDependencies": "error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"globals": ["Alpine", "$", "jQuery", "gettext", "interpolate"]
|
"globals": ["Alpine", "gettext", "interpolate"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -13,8 +13,10 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.forms.models import ModelForm
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Club)
|
@admin.register(Club)
|
||||||
@@ -29,6 +31,31 @@ class ClubAdmin(admin.ModelAdmin):
|
|||||||
"page",
|
"page",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def save_model(
|
||||||
|
self,
|
||||||
|
request: HttpRequest,
|
||||||
|
obj: Club,
|
||||||
|
form: ModelForm,
|
||||||
|
change: bool, # noqa: FBT001
|
||||||
|
):
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
if not change:
|
||||||
|
obj.create_default_roles()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ClubRole)
|
||||||
|
class ClubRoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "club", "is_board", "is_presidency")
|
||||||
|
search_fields = ("name",)
|
||||||
|
autocomplete_fields = ("club",)
|
||||||
|
list_select_related = ("club",)
|
||||||
|
list_filter = (
|
||||||
|
"is_board",
|
||||||
|
"is_presidency",
|
||||||
|
("club", admin.RelatedOnlyFieldListFilter),
|
||||||
|
)
|
||||||
|
show_facets = admin.ModelAdmin.show_facets.ALWAYS
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Membership)
|
@admin.register(Membership)
|
||||||
class MembershipAdmin(admin.ModelAdmin):
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
@@ -40,3 +67,18 @@ class MembershipAdmin(admin.ModelAdmin):
|
|||||||
"club__name",
|
"club__name",
|
||||||
)
|
)
|
||||||
autocomplete_fields = ("user",)
|
autocomplete_fields = ("user",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LinkType)
|
||||||
|
class LinkTypeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "url_base", "icon")
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ClubLink)
|
||||||
|
class ClubLinkAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("link_type", "club", "url")
|
||||||
|
list_select_related = ("link_type", "club")
|
||||||
|
autocomplete_fields = ("link_type", "club")
|
||||||
|
search_fields = ("link_type__name", "url")
|
||||||
|
list_filter = ("link_type", ("club", admin.RelatedOnlyFieldListFilter))
|
||||||
|
|||||||
+72
-7
@@ -6,9 +6,16 @@ from ninja_extra.pagination import PageNumberPaginationExtra
|
|||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from api.auth import ApiKeyAuth
|
from api.auth import ApiKeyAuth
|
||||||
from api.permissions import CanAccessLookup, HasPerm
|
from api.permissions import CanView, HasPerm
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
|
from club.schemas import (
|
||||||
|
ClubSchema,
|
||||||
|
ClubSearchFilterSchema,
|
||||||
|
MembershipFilterSchema,
|
||||||
|
SimpleClubSchema,
|
||||||
|
UserMembershipSchema,
|
||||||
|
)
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/club")
|
@api_controller("/club")
|
||||||
@@ -16,13 +23,11 @@ class ClubController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SimpleClubSchema],
|
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||||
auth=[ApiKeyAuth(), SessionAuth()],
|
|
||||||
permissions=[CanAccessLookup],
|
|
||||||
url_name="search_club",
|
url_name="search_club",
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_club(self, filters: Query[ClubSearchFilterSchema]):
|
def search_club(self, filters: Query[ClubSearchFilterSchema]):
|
||||||
return filters.filter(Club.objects.all())
|
return filters.filter(Club.objects.order_by("name")).values()
|
||||||
|
|
||||||
@route.get(
|
@route.get(
|
||||||
"/{int:club_id}",
|
"/{int:club_id}",
|
||||||
@@ -33,8 +38,68 @@ class ClubController(ControllerBase):
|
|||||||
)
|
)
|
||||||
def fetch_club(self, club_id: int):
|
def fetch_club(self, club_id: int):
|
||||||
prefetch = Prefetch(
|
prefetch = Prefetch(
|
||||||
"members", queryset=Membership.objects.ongoing().select_related("user")
|
"members",
|
||||||
|
queryset=Membership.objects.ongoing().select_related("user", "role"),
|
||||||
)
|
)
|
||||||
return self.get_object_or_exception(
|
return self.get_object_or_exception(
|
||||||
Club.objects.prefetch_related(prefetch), id=club_id
|
Club.objects.prefetch_related(prefetch, "links"), id=club_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/user/{int:user_id}/club")
|
||||||
|
class UserClubController(ControllerBase):
|
||||||
|
@route.get(
|
||||||
|
"",
|
||||||
|
response=list[UserMembershipSchema],
|
||||||
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
|
permissions=[CanView],
|
||||||
|
url_name="fetch_user_clubs",
|
||||||
|
)
|
||||||
|
def fetch_user_clubs(self, user_id: int):
|
||||||
|
"""Get all the active memberships of the given user."""
|
||||||
|
user = self.get_object_or_exception(User, id=user_id)
|
||||||
|
return (
|
||||||
|
Membership.objects.ongoing()
|
||||||
|
.filter(user=user)
|
||||||
|
.select_related("club", "user", "role")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/clubs/members/")
|
||||||
|
class ClubMembershipController(ControllerBase):
|
||||||
|
@route.get(
|
||||||
|
"/new",
|
||||||
|
response=list[UserMembershipSchema],
|
||||||
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
|
permissions=[HasPerm("club.view_club")],
|
||||||
|
url_name="get_new_clubs_members_since_date",
|
||||||
|
)
|
||||||
|
def fetch_new_club_members(self, filters: Query[MembershipFilterSchema]):
|
||||||
|
"""give all the members of all clubs that have joined since a given date"""
|
||||||
|
memberships = (
|
||||||
|
Membership.objects.ongoing()
|
||||||
|
.filter(start_date__gte=filters.since_date, end_date__isnull=True)
|
||||||
|
.select_related("user", "role", "club")
|
||||||
|
)
|
||||||
|
if filters.clubs_id:
|
||||||
|
memberships = memberships.filter(club_id__in=filters.clubs_id)
|
||||||
|
|
||||||
|
return memberships.order_by("start_date")
|
||||||
|
|
||||||
|
@route.get(
|
||||||
|
"/former",
|
||||||
|
response=list[UserMembershipSchema],
|
||||||
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
|
permissions=[HasPerm("club.view_club")],
|
||||||
|
url_name="get_former_clubs_members_since_date",
|
||||||
|
)
|
||||||
|
def fetch_former_club_members(self, filters: Query[MembershipFilterSchema]):
|
||||||
|
"""give all the former members of all clubs that have left since a given date"""
|
||||||
|
memberships = Membership.objects.filter(
|
||||||
|
start_date__lt=filters.since_date,
|
||||||
|
end_date__gte=filters.since_date,
|
||||||
|
).select_related("user", "role", "club")
|
||||||
|
if filters.clubs_id:
|
||||||
|
memberships = memberships.filter(club_id__in=filters.clubs_id)
|
||||||
|
|
||||||
|
return memberships.order_by("start_date")
|
||||||
|
|||||||
+215
-27
@@ -21,15 +21,24 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
import itertools
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||||
from django.db.models import Exists, OuterRef, Q
|
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
|
from django.forms.models import ModelChoiceField, ModelChoiceIterator
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
from club.models import (
|
||||||
|
Club,
|
||||||
|
ClubLink,
|
||||||
|
ClubRole,
|
||||||
|
Mailing,
|
||||||
|
MailingSubscription,
|
||||||
|
Membership,
|
||||||
|
)
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDateTime
|
from core.views.forms import SelectDateTime
|
||||||
from core.views.widgets.ajax_select import (
|
from core.views.widgets.ajax_select import (
|
||||||
@@ -37,6 +46,58 @@ from core.views.widgets.ajax_select import (
|
|||||||
AutoCompleteSelectUser,
|
AutoCompleteSelectUser,
|
||||||
)
|
)
|
||||||
from counter.models import Counter, Selling
|
from counter.models import Counter, Selling
|
||||||
|
from counter.schemas import SaleFilterSchema
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleChoiceIterator(ModelChoiceIterator):
|
||||||
|
"""Custom `ModelChoiceIterator` for `ClubRoleChoiceField`"""
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
if self.field.empty_label is not None:
|
||||||
|
yield "", self.field.empty_label
|
||||||
|
queryset = self.queryset.select_related("club").order_by("club", "order")
|
||||||
|
groups = [
|
||||||
|
(club, [self.choice(role) for role in roles])
|
||||||
|
for club, roles in itertools.groupby(queryset, key=attrgetter("club"))
|
||||||
|
]
|
||||||
|
if len(groups) == 1:
|
||||||
|
# there is only one club involved, no need to have optgroups
|
||||||
|
yield from groups[0][1]
|
||||||
|
else:
|
||||||
|
# there are multiple clubs, optgroups are necessary to differentiate
|
||||||
|
# roles having the same name
|
||||||
|
yield from groups
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleChoiceField(ModelChoiceField):
|
||||||
|
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
|
||||||
|
|
||||||
|
If only one club is involved, behave like the base `ModelChoiceField`.
|
||||||
|
If dealing with the roles of multiple clubs, group the roles
|
||||||
|
into a different `optgroup` for each club.
|
||||||
|
"""
|
||||||
|
|
||||||
|
iterator = ClubRoleChoiceIterator
|
||||||
|
|
||||||
|
|
||||||
|
class ClubLinkForm(forms.ModelForm):
|
||||||
|
error_css_class = "error"
|
||||||
|
required_css_class = "required"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ClubLink
|
||||||
|
fields = ["url", "name", "link_type"]
|
||||||
|
widgets = {
|
||||||
|
"url": forms.URLInput(
|
||||||
|
{"pattern": "https://.*", "placeholder": "https://monlien.com"}
|
||||||
|
),
|
||||||
|
"link_type": forms.HiddenInput(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ClubLinkFormSet = forms.inlineformset_factory(
|
||||||
|
Club, ClubLink, ClubLinkForm, extra=0, can_delete_extra=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClubEditForm(forms.ModelForm):
|
class ClubEditForm(forms.ModelForm):
|
||||||
@@ -48,6 +109,20 @@ class ClubEditForm(forms.ModelForm):
|
|||||||
fields = ["address", "logo", "short_description"]
|
fields = ["address", "logo", "short_description"]
|
||||||
widgets = {"short_description": forms.Textarea()}
|
widgets = {"short_description": forms.Textarea()}
|
||||||
|
|
||||||
|
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
|
||||||
|
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
|
||||||
|
self.link_formset = ClubLinkFormSet(
|
||||||
|
*args, instance=self.instance, prefix="link", **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return super().is_valid() and self.link_formset.is_valid()
|
||||||
|
|
||||||
|
def save(self, commit=True): # noqa: FBT002
|
||||||
|
res = super().save(commit=commit)
|
||||||
|
self.link_formset.save(commit=commit)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
class ClubAdminEditForm(ClubEditForm):
|
class ClubAdminEditForm(ClubEditForm):
|
||||||
admin_fields = ["name", "parent", "is_active"]
|
admin_fields = ["name", "parent", "is_active"]
|
||||||
@@ -191,6 +266,18 @@ class SellingsForm(forms.Form):
|
|||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def to_filter_schema(self) -> SaleFilterSchema:
|
||||||
|
products = (
|
||||||
|
*self.cleaned_data["products"],
|
||||||
|
*self.cleaned_data["archived_products"],
|
||||||
|
)
|
||||||
|
return SaleFilterSchema(
|
||||||
|
after=self.cleaned_data["begin_date"],
|
||||||
|
before=self.cleaned_data["end_date"],
|
||||||
|
counters={c.id for c in self.cleaned_data["counters"]} or None,
|
||||||
|
products={p.id for p in products} or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClubOldMemberForm(forms.Form):
|
class ClubOldMemberForm(forms.Form):
|
||||||
members_old = forms.ModelMultipleChoiceField(
|
members_old = forms.ModelMultipleChoiceField(
|
||||||
@@ -202,9 +289,7 @@ class ClubOldMemberForm(forms.Form):
|
|||||||
|
|
||||||
def __init__(self, *args, user: User, club: Club, **kwargs):
|
def __init__(self, *args, user: User, club: Club, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["members_old"].queryset = (
|
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
|
||||||
Membership.objects.ongoing().filter(club=club).editable_by(user)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClubMemberForm(forms.ModelForm):
|
class ClubMemberForm(forms.ModelForm):
|
||||||
@@ -222,19 +307,14 @@ class ClubMemberForm(forms.ModelForm):
|
|||||||
self.request_user = request_user
|
self.request_user = request_user
|
||||||
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["role"].required = True
|
self.fields["role"].queryset = self.available_roles
|
||||||
self.fields["role"].choices = [
|
|
||||||
(value, name)
|
|
||||||
for value, name in settings.SITH_CLUB_ROLES.items()
|
|
||||||
if value <= self.max_available_role
|
|
||||||
]
|
|
||||||
self.instance.club = club
|
self.instance.club = club
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_available_role(self):
|
def available_roles(self) -> QuerySet[ClubRole]:
|
||||||
"""The greatest role that will be obtainable with this form."""
|
"""The roles that will be obtainable with this form."""
|
||||||
# this is unreachable, because it will be overridden by subclasses
|
# this is unreachable, because it will be overridden by subclasses
|
||||||
return -1 # pragma: no cover
|
return ClubRole.objects.none() # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class ClubAddMemberForm(ClubMemberForm):
|
class ClubAddMemberForm(ClubMemberForm):
|
||||||
@@ -245,21 +325,22 @@ class ClubAddMemberForm(ClubMemberForm):
|
|||||||
widgets = {"user": AutoCompleteSelectUser}
|
widgets = {"user": AutoCompleteSelectUser}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def max_available_role(self):
|
def available_roles(self):
|
||||||
"""The greatest role that will be obtainable with this form.
|
"""The roles that will be obtainable with this form.
|
||||||
|
|
||||||
Admins and the club president can attribute any role.
|
Admins and the club president can attribute any role.
|
||||||
Board members can attribute roles lower than their own.
|
Board members can attribute roles lower than their own.
|
||||||
Other users cannot attribute roles with this form
|
Other users cannot attribute roles with this form
|
||||||
"""
|
"""
|
||||||
|
qs = self.club.roles.filter(is_active=True)
|
||||||
if self.request_user.has_perm("club.add_membership"):
|
if self.request_user.has_perm("club.add_membership"):
|
||||||
return settings.SITH_CLUB_ROLES_ID["President"]
|
return qs.all()
|
||||||
membership = self.request_user_membership
|
membership = self.request_user_membership
|
||||||
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
if membership is None or not membership.role.is_board:
|
||||||
return -1
|
return ClubRole.objects.none()
|
||||||
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
|
if membership.role.is_presidency:
|
||||||
return membership.role
|
return qs.all()
|
||||||
return membership.role - 1
|
return qs.above_instance(membership.role)
|
||||||
|
|
||||||
def clean_user(self):
|
def clean_user(self):
|
||||||
"""Check that the user is not trying to add a user already in the club.
|
"""Check that the user is not trying to add a user already in the club.
|
||||||
@@ -283,13 +364,11 @@ class JoinClubForm(ClubMemberForm):
|
|||||||
|
|
||||||
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
||||||
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
||||||
# this form doesn't manage the user who will join the club,
|
|
||||||
# so we must set this here to avoid errors
|
|
||||||
self.instance.user = self.request_user
|
self.instance.user = self.request_user
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def max_available_role(self):
|
def available_roles(self):
|
||||||
return settings.SITH_MAXIMUM_FREE_ROLE
|
return self.club.roles.filter(is_board=False, is_active=True)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Check that the user is subscribed and isn't already in the club."""
|
"""Check that the user is subscribed and isn't already in the club."""
|
||||||
@@ -302,3 +381,112 @@ class JoinClubForm(ClubMemberForm):
|
|||||||
_("You are already a member of this club"), code="invalid"
|
_("You are already a member of this club"), code="invalid"
|
||||||
)
|
)
|
||||||
return super().clean()
|
return super().clean()
|
||||||
|
|
||||||
|
|
||||||
|
class ClubSearchForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Club
|
||||||
|
fields = ["name"]
|
||||||
|
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
|
||||||
|
|
||||||
|
club_status = forms.NullBooleanField(
|
||||||
|
label=_("Club status"),
|
||||||
|
widget=forms.RadioSelect(
|
||||||
|
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
|
||||||
|
),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, data: dict | None = None, **kwargs):
|
||||||
|
super().__init__(*args, data=data, **kwargs)
|
||||||
|
if data is not None and "club_status" not in data:
|
||||||
|
# if the key is missing, it is considered as None,
|
||||||
|
# even though we want the default True value to be applied in such a case
|
||||||
|
# so we enforce it.
|
||||||
|
self.fields["club_status"].value = True
|
||||||
|
self.fields["name"].required = False
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleForm(forms.ModelForm):
|
||||||
|
error_css_class = "error"
|
||||||
|
required_css_class = "required"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ClubRole
|
||||||
|
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
|
||||||
|
widgets = {
|
||||||
|
"is_presidency": forms.HiddenInput(),
|
||||||
|
"is_board": forms.HiddenInput(),
|
||||||
|
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
if "ORDER" in cleaned_data:
|
||||||
|
self.instance.order = cleaned_data["ORDER"] - 1
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, commit=True): # noqa: FBT002
|
||||||
|
instance: ClubRole = super().save(commit=commit)
|
||||||
|
if commit and "is_board" in self.changed_data:
|
||||||
|
# if the role was moved from board to simple member,
|
||||||
|
# remove all users with that role from the club board group.
|
||||||
|
# If the role became a board role, add users with
|
||||||
|
# that role to the club board group.
|
||||||
|
group_id = instance.club.board_group_id
|
||||||
|
if self.cleaned_data["is_board"]:
|
||||||
|
User.groups.through.objects.bulk_create(
|
||||||
|
[
|
||||||
|
User.groups.through(user_id=u, group_id=group_id)
|
||||||
|
for u in Membership.objects.ongoing()
|
||||||
|
.filter(role=instance)
|
||||||
|
.values_list("user_id", flat=True)
|
||||||
|
],
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
User.groups.through.objects.filter(
|
||||||
|
user__memberships__role=instance, group_id=group_id
|
||||||
|
).delete()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleCreateForm(forms.ModelForm):
|
||||||
|
"""Form to create a club role.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
For UX purposes, users are not meant to fill `is_presidency`
|
||||||
|
and `is_board`, so those values are required by the form constructor
|
||||||
|
in order to initialize the instance properly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
error_css_class = "error"
|
||||||
|
required_css_class = "required"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ClubRole
|
||||||
|
fields = ["name", "description"]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.instance.club = club
|
||||||
|
self.instance.is_presidency = is_presidency
|
||||||
|
self.instance.is_board = is_board
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
|
||||||
|
ordering_widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
|
||||||
|
ClubRoleFormSet = forms.inlineformset_factory(
|
||||||
|
Club,
|
||||||
|
ClubRole,
|
||||||
|
ClubRoleForm,
|
||||||
|
ClubRoleBaseFormSet,
|
||||||
|
can_delete=False,
|
||||||
|
can_order=True,
|
||||||
|
edit_only=True,
|
||||||
|
extra=0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.db.models.functions.datetime
|
import django.db.models.functions.datetime
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.db.migrations.state import StateApps
|
from django.db.migrations.state import StateApps
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
|
|
||||||
|
# Before the club role rework, the maximum free role
|
||||||
|
# was the hardcoded highest non-board role
|
||||||
|
MAXIMUM_FREE_ROLE = 1
|
||||||
|
|
||||||
|
|
||||||
def migrate_meta_groups(apps: StateApps, schema_editor):
|
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||||
"""Attach the existing meta groups to the clubs.
|
"""Attach the existing meta groups to the clubs.
|
||||||
@@ -46,10 +49,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
|
|||||||
).select_related("user")
|
).select_related("user")
|
||||||
club.members_group.users.set([m.user for m in memberships])
|
club.members_group.users.set([m.user for m in memberships])
|
||||||
club.board_group.users.set(
|
club.board_group.users.set(
|
||||||
[
|
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
|
||||||
m.user
|
|
||||||
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-06-21 21:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import Case, When
|
||||||
|
|
||||||
|
PRESIDENCY_ROLES = [10, 9]
|
||||||
|
MAXIMUM_FREE_ROLE = 1
|
||||||
|
SITH_CLUB_ROLES = {
|
||||||
|
10: "Président⸱e",
|
||||||
|
9: "Vice-Président⸱e",
|
||||||
|
7: "Trésorier⸱e",
|
||||||
|
5: "Responsable communication",
|
||||||
|
4: "Secrétaire",
|
||||||
|
3: "Responsable info",
|
||||||
|
2: "Membre du bureau",
|
||||||
|
1: "Membre actif⸱ve",
|
||||||
|
0: "Curieux⸱euse",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_roles(apps: StateApps, schema_editor):
|
||||||
|
ClubRole = apps.get_model("club", "ClubRole")
|
||||||
|
Membership = apps.get_model("club", "Membership")
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
for club_id, role in Membership.objects.values_list("club", "role").distinct():
|
||||||
|
new_role = ClubRole.objects.create(
|
||||||
|
name=SITH_CLUB_ROLES[role],
|
||||||
|
is_board=role > MAXIMUM_FREE_ROLE,
|
||||||
|
is_presidency=role in PRESIDENCY_ROLES,
|
||||||
|
club_id=club_id,
|
||||||
|
order=max(SITH_CLUB_ROLES) - role,
|
||||||
|
)
|
||||||
|
updates.append(When(club_id=club_id, role=role, then=new_role.id))
|
||||||
|
# all updates must happen at the same time
|
||||||
|
# otherwise, the 10 first created ClubRole would be
|
||||||
|
# re-modified after their initial creation, and it would
|
||||||
|
# result in an incoherent state.
|
||||||
|
# To avoid that, all updates are wrapped in a single giant Case(When) statement
|
||||||
|
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
|
||||||
|
Membership.objects.update(role=Case(*updates))
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
|
||||||
|
("core", "0047_alter_notification_date_alter_notification_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="club",
|
||||||
|
name="page",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club",
|
||||||
|
to="core.page",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ClubRole",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"order",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
db_index=True, editable=False, verbose_name="order"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"club",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="The club with which this role is associated",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="roles",
|
||||||
|
to="club.club",
|
||||||
|
verbose_name="club",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=50, verbose_name="name")),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
default="", blank=True, verbose_name="description"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_board",
|
||||||
|
models.BooleanField(default=False, verbose_name="Board role"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_presidency",
|
||||||
|
models.BooleanField(default=False, verbose_name="Presidency role"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text=(
|
||||||
|
"If the role is inactive, people joining the club "
|
||||||
|
"won't be able to get it."
|
||||||
|
),
|
||||||
|
verbose_name="is active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ("order",),
|
||||||
|
"verbose_name": "club role",
|
||||||
|
"verbose_name_plural": "club roles",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="club",
|
||||||
|
name="board_group",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
editable=False,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club_board",
|
||||||
|
to="core.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="club",
|
||||||
|
name="members_group",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
editable=False,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club",
|
||||||
|
to="core.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="clubrole",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(
|
||||||
|
("is_presidency", False), ("is_board", True), _connector="OR"
|
||||||
|
),
|
||||||
|
name="clubrole_presidency_implies_board",
|
||||||
|
violation_error_message=(
|
||||||
|
"A role cannot be in the presidency while not being in the board"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
|
||||||
|
# because Postgres migrations run in a single transaction,
|
||||||
|
# we cannot change the actual values of Membership.role
|
||||||
|
# and apply the FOREIGN KEY constraint in the same migration.
|
||||||
|
# The constraint is created in the next migration
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-09-27 09:57
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("club", "0015_clubrole_alter_membership_role")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# because Postgres migrations run in a single transaction,
|
||||||
|
# we cannot change the actual values of Membership.role
|
||||||
|
# and apply the FOREIGN KEY constraint in the same migration.
|
||||||
|
# The data migration was made in the previous migration.
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="membership",
|
||||||
|
name="role",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="members",
|
||||||
|
to="club.clubrole",
|
||||||
|
verbose_name="role",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-27 07:39
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("club", "0016_clubrole_alter_membership_role")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="LinkType",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=40, verbose_name="name")),
|
||||||
|
(
|
||||||
|
"url_base",
|
||||||
|
models.URLField(
|
||||||
|
help_text=(
|
||||||
|
"The base url that links with this type must respect"
|
||||||
|
),
|
||||||
|
unique=True,
|
||||||
|
verbose_name="url base",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"icon",
|
||||||
|
models.CharField(
|
||||||
|
help_text=(
|
||||||
|
"The fontawesome class to use "
|
||||||
|
"(e.g. `fa-brands fa-instagram`)"
|
||||||
|
),
|
||||||
|
max_length=40,
|
||||||
|
verbose_name="icon",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={"verbose_name": "link type", "verbose_name_plural": "link types"},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ClubLink",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(blank=True, max_length=40, verbose_name="name"),
|
||||||
|
),
|
||||||
|
("url", models.URLField(verbose_name="link url")),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"club",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="links",
|
||||||
|
to="club.club",
|
||||||
|
verbose_name="club",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"link_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="links",
|
||||||
|
to="club.linktype",
|
||||||
|
verbose_name="link type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "club link",
|
||||||
|
"verbose_name_plural": "club links",
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["club", "url"],
|
||||||
|
name="club_clublink_unique_club_url",
|
||||||
|
violation_error_message="Duplicated url",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
+249
-77
@@ -26,18 +26,17 @@ from __future__ import annotations
|
|||||||
from typing import Iterable, Self
|
from typing import Iterable, Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.core.validators import RegexValidator, validate_email
|
from django.core.validators import RegexValidator, validate_email
|
||||||
from django.db import models, transaction
|
from django.db import ProgrammingError, models, transaction
|
||||||
from django.db.models import Exists, F, OuterRef, Q, Value
|
from django.db.models import Exists, F, OuterRef, Q
|
||||||
from django.db.models.functions import Greatest
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from ordered_model.models import OrderedModel
|
||||||
|
|
||||||
from core.fields import ResizedImageField
|
from core.fields import ResizedImageField
|
||||||
from core.models import Group, Notification, Page, SithFile, User
|
from core.models import Group, Notification, Page, SithFile, User
|
||||||
@@ -90,13 +89,13 @@ class Club(models.Model):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
page = models.OneToOneField(
|
page = models.OneToOneField(
|
||||||
Page, related_name="club", blank=True, on_delete=models.CASCADE
|
Page, related_name="club", blank=True, on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
members_group = models.OneToOneField(
|
members_group = models.OneToOneField(
|
||||||
Group, related_name="club", on_delete=models.PROTECT
|
Group, related_name="club", on_delete=models.PROTECT, editable=False
|
||||||
)
|
)
|
||||||
board_group = models.OneToOneField(
|
board_group = models.OneToOneField(
|
||||||
Group, related_name="club_board", on_delete=models.PROTECT
|
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ClubQuerySet.as_manager()
|
objects = ClubQuerySet.as_manager()
|
||||||
@@ -139,9 +138,7 @@ class Club(models.Model):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def president(self) -> Membership | None:
|
def president(self) -> Membership | None:
|
||||||
"""Fetch the membership of the current president of this club."""
|
"""Fetch the membership of the current president of this club."""
|
||||||
return self.members.filter(
|
return self.members.filter(end_date=None).order_by("role__order").first()
|
||||||
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
|
||||||
).first()
|
|
||||||
|
|
||||||
def check_loop(self):
|
def check_loop(self):
|
||||||
"""Raise a validation error when a loop is found within the parent list."""
|
"""Raise a validation error when a loop is found within the parent list."""
|
||||||
@@ -186,10 +183,41 @@ class Club(models.Model):
|
|||||||
self.page.parent = self.parent.page
|
self.page.parent = self.parent.page
|
||||||
self.page.save(force_lock=True)
|
self.page.save(force_lock=True)
|
||||||
|
|
||||||
|
def create_default_roles(self):
|
||||||
|
"""Create some roles that should exist by default for this club.
|
||||||
|
|
||||||
|
The created roles are : president, treasurer, active member and curious.
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
When calling this method, no club must exist yet for this club.
|
||||||
|
"""
|
||||||
|
if self.roles.exists():
|
||||||
|
raise ProgrammingError(
|
||||||
|
"Default roles can be created only for clubs "
|
||||||
|
"that don't have associated roles yet"
|
||||||
|
)
|
||||||
|
# The names are written in French, because there is no gettext involved
|
||||||
|
# for strings stored in database, and the majority of users are french.
|
||||||
|
roles = [
|
||||||
|
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
|
||||||
|
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
|
||||||
|
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
|
||||||
|
ClubRole(
|
||||||
|
name="Curieux⸱euse",
|
||||||
|
description=(
|
||||||
|
"Les gens qui suivent l'activité "
|
||||||
|
"du club sans forcément y participer"
|
||||||
|
),
|
||||||
|
is_board=False,
|
||||||
|
is_presidency=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for i, role in enumerate(roles):
|
||||||
|
role.club = self
|
||||||
|
role.order = i
|
||||||
|
ClubRole.objects.bulk_create(roles)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||||
# Invalidate the cache of this club and of its memberships
|
|
||||||
for membership in self.members.ongoing().select_related("user"):
|
|
||||||
cache.delete(f"membership_{self.id}_{membership.user.id}")
|
|
||||||
self.board_group.delete()
|
self.board_group.delete()
|
||||||
self.members_group.delete()
|
self.members_group.delete()
|
||||||
return super().delete(*args, **kwargs)
|
return super().delete(*args, **kwargs)
|
||||||
@@ -210,29 +238,120 @@ class Club(models.Model):
|
|||||||
"""Method to see if that object can be edited by the given user."""
|
"""Method to see if that object can be edited by the given user."""
|
||||||
return self.has_rights_in_club(user)
|
return self.has_rights_in_club(user)
|
||||||
|
|
||||||
def get_membership_for(self, user: User) -> Membership | None:
|
def can_roles_be_edited_by(self, user: User) -> bool:
|
||||||
"""Return the current membership the given user.
|
"""Return True if the given user can edit the roles of this club"""
|
||||||
|
return user.is_authenticated and (
|
||||||
|
user.has_perm("club.change_clubrole")
|
||||||
|
or self.members.ongoing()
|
||||||
|
.filter(user=user, role__is_presidency=True)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
Note:
|
@cached_property
|
||||||
The result is cached.
|
def current_members(self) -> list[Membership]:
|
||||||
"""
|
return list(
|
||||||
|
self.members.ongoing().select_related("user", "role").order_by("-role")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_membership_for(self, user: User) -> Membership | None:
|
||||||
|
"""Return the current membership of the given user."""
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return None
|
return None
|
||||||
membership = cache.get(f"membership_{self.id}_{user.id}")
|
return next((m for m in self.current_members if m.user_id == user.id), None)
|
||||||
if membership == "not_member":
|
|
||||||
return None
|
|
||||||
if membership is None:
|
|
||||||
membership = self.members.filter(user=user, end_date=None).first()
|
|
||||||
if membership is None:
|
|
||||||
cache.set(f"membership_{self.id}_{user.id}", "not_member")
|
|
||||||
else:
|
|
||||||
cache.set(f"membership_{self.id}_{user.id}", membership)
|
|
||||||
return membership
|
|
||||||
|
|
||||||
def has_rights_in_club(self, user: User) -> bool:
|
def has_rights_in_club(self, user: User) -> bool:
|
||||||
return user.is_in_group(pk=self.board_group_id)
|
return user.is_in_group(pk=self.board_group_id)
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRole(OrderedModel):
|
||||||
|
club = models.ForeignKey(
|
||||||
|
Club,
|
||||||
|
verbose_name=_("club"),
|
||||||
|
help_text=_("The club with which this role is associated"),
|
||||||
|
related_name="roles",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
name = models.CharField(_("name"), max_length=50)
|
||||||
|
description = models.TextField(_("description"), blank=True, default="")
|
||||||
|
is_board = models.BooleanField(_("Board role"), default=False)
|
||||||
|
is_presidency = models.BooleanField(_("Presidency role"), default=False)
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
_("is active"),
|
||||||
|
default=True,
|
||||||
|
help_text=_(
|
||||||
|
"If the role is inactive, people joining the club won't be able to get it."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
order_with_respect_to = "club"
|
||||||
|
|
||||||
|
class Meta(OrderedModel.Meta):
|
||||||
|
verbose_name = _("club role")
|
||||||
|
verbose_name_plural = _("club roles")
|
||||||
|
constraints = [
|
||||||
|
# presidency IMPLIES board <=> NOT presidency OR board
|
||||||
|
# cf. MT1 :)
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=Q(is_presidency=False) | Q(is_board=True),
|
||||||
|
name="clubrole_presidency_implies_board",
|
||||||
|
violation_error_message=_(
|
||||||
|
"A role cannot be in the presidency while not being in the board"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_display_name(self):
|
||||||
|
return f"{self.name} - {self.club.name}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
errors = []
|
||||||
|
roles = list(self.club.roles.all())
|
||||||
|
if (
|
||||||
|
self.is_board
|
||||||
|
and self.order
|
||||||
|
and any(r.order < self.order and not r.is_board for r in roles)
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
ValidationError(
|
||||||
|
_("Role %(role)s cannot be placed below a member role")
|
||||||
|
% {"role": self.name}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.is_presidency
|
||||||
|
and self.order
|
||||||
|
and any(r.order < self.order and not r.is_presidency for r in roles)
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
ValidationError(
|
||||||
|
_("Role %(role)s cannot be placed below a non-presidency role")
|
||||||
|
% {"role": self.name}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
return super().clean()
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
auto_order = self.order is None and self.is_board
|
||||||
|
if not auto_order:
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
return
|
||||||
|
# get the role that should be placed after the role we are dealing with.
|
||||||
|
# So, if this is role is presidency, get the first board role ;
|
||||||
|
# if it is a board role, get the first member role ;
|
||||||
|
# and if it is a member role, get nothing (OrderedModel.save will
|
||||||
|
# automatically put it in the last position anyway)
|
||||||
|
filters = {"is_board": self.is_presidency, "is_presidency": False}
|
||||||
|
next_role = self.club.roles.filter(**filters).order_by("order").first()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
if next_role:
|
||||||
|
self.above(next_role)
|
||||||
|
|
||||||
|
|
||||||
class MembershipQuerySet(models.QuerySet):
|
class MembershipQuerySet(models.QuerySet):
|
||||||
def ongoing(self) -> Self:
|
def ongoing(self) -> Self:
|
||||||
"""Filter all memberships which are not finished yet."""
|
"""Filter all memberships which are not finished yet."""
|
||||||
@@ -245,9 +364,10 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
are included, even if there are no more members.
|
are included, even if there are no more members.
|
||||||
|
|
||||||
If you want to get the users who are currently in the board,
|
If you want to get the users who are currently in the board,
|
||||||
mind combining this with the :meth:`ongoing` queryset method
|
mind combining this with the [MembershipQuerySet.ongoing][]
|
||||||
|
queryset method
|
||||||
"""
|
"""
|
||||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
return self.filter(role__is_board=True)
|
||||||
|
|
||||||
def editable_by(self, user: User) -> Self:
|
def editable_by(self, user: User) -> Self:
|
||||||
"""Filter Memberships that this user can edit.
|
"""Filter Memberships that this user can edit.
|
||||||
@@ -270,60 +390,42 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
"""
|
"""
|
||||||
if user.has_perm("club.change_membership"):
|
if user.has_perm("club.change_membership"):
|
||||||
return self.all()
|
return self.all()
|
||||||
return self.filter(
|
return self.ongoing().filter(
|
||||||
Q(user=user)
|
Q(user=user)
|
||||||
| Exists(
|
| Exists(
|
||||||
Membership.objects.filter(
|
Membership.objects.ongoing().filter(
|
||||||
Q(
|
|
||||||
role__gt=Greatest(
|
|
||||||
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
user=user,
|
user=user,
|
||||||
end_date=None,
|
|
||||||
club=OuterRef("club"),
|
club=OuterRef("club"),
|
||||||
|
role__is_board=True,
|
||||||
|
role__order__lt=OuterRef("role__order"),
|
||||||
)
|
)
|
||||||
),
|
)
|
||||||
end_date=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update(self, **kwargs) -> int:
|
def update(self, **kwargs) -> int:
|
||||||
"""Refresh the cache and edit group ownership.
|
"""Remove users from club groups they are no more in
|
||||||
|
|
||||||
Update the cache, when necessary, remove
|
|
||||||
users from club groups they are no more in
|
|
||||||
and add them in the club groups they should be in.
|
and add them in the club groups they should be in.
|
||||||
|
|
||||||
Be aware that this adds three db queries :
|
Be aware that this adds three db queries :
|
||||||
one to retrieve the updated memberships,
|
|
||||||
one to perform group removal and one to perform
|
- one to retrieve the updated memberships
|
||||||
group attribution.
|
- one to perform group removal
|
||||||
|
- and one to perform group attribution.
|
||||||
"""
|
"""
|
||||||
nb_rows = super().update(**kwargs)
|
nb_rows = super().update(**kwargs)
|
||||||
if nb_rows == 0:
|
if nb_rows == 0:
|
||||||
# if no row was affected, no need to refresh the cache
|
# if no row was affected, no need to edit club groups
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
cache_memberships = {}
|
|
||||||
memberships = set(self.select_related("club"))
|
memberships = set(self.select_related("club"))
|
||||||
# delete all User-Group relations and recreate the necessary ones
|
# delete all User-Group relations and recreate the necessary ones
|
||||||
# It's more concise to write and more reliable
|
|
||||||
Membership._remove_club_groups(memberships)
|
Membership._remove_club_groups(memberships)
|
||||||
Membership._add_club_groups(memberships)
|
Membership._add_club_groups(memberships)
|
||||||
for member in memberships:
|
|
||||||
cache_key = f"membership_{member.club_id}_{member.user_id}"
|
|
||||||
if member.end_date is None:
|
|
||||||
cache_memberships[cache_key] = member
|
|
||||||
else:
|
|
||||||
cache_memberships[cache_key] = "not_member"
|
|
||||||
cache.set_many(cache_memberships)
|
|
||||||
return nb_rows
|
return nb_rows
|
||||||
|
|
||||||
def delete(self) -> tuple[int, dict[str, int]]:
|
def delete(self) -> tuple[int, dict[str, int]]:
|
||||||
"""Work just like the default Django's delete() method,
|
"""Work just like the default Django's delete() method,
|
||||||
but add a cache invalidation for the elements of the queryset
|
but also remove the concerned users from the club groups.
|
||||||
before the deletion,
|
|
||||||
and a removal of the user from the club groups.
|
|
||||||
|
|
||||||
Be aware that this adds some db queries :
|
Be aware that this adds some db queries :
|
||||||
|
|
||||||
@@ -339,12 +441,6 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
nb_rows, rows_counts = super().delete()
|
nb_rows, rows_counts = super().delete()
|
||||||
if nb_rows > 0:
|
if nb_rows > 0:
|
||||||
Membership._remove_club_groups(memberships)
|
Membership._remove_club_groups(memberships)
|
||||||
cache.set_many(
|
|
||||||
{
|
|
||||||
f"membership_{m.club_id}_{m.user_id}": "not_member"
|
|
||||||
for m in memberships
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return nb_rows, rows_counts
|
return nb_rows, rows_counts
|
||||||
|
|
||||||
|
|
||||||
@@ -373,10 +469,11 @@ class Membership(models.Model):
|
|||||||
)
|
)
|
||||||
start_date = models.DateField(_("start date"), default=timezone.now)
|
start_date = models.DateField(_("start date"), default=timezone.now)
|
||||||
end_date = models.DateField(_("end date"), null=True, blank=True)
|
end_date = models.DateField(_("end date"), null=True, blank=True)
|
||||||
role = models.IntegerField(
|
role = models.ForeignKey(
|
||||||
_("role"),
|
ClubRole,
|
||||||
choices=sorted(settings.SITH_CLUB_ROLES.items()),
|
verbose_name=_("role"),
|
||||||
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
|
related_name="members",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
description = models.CharField(
|
description = models.CharField(
|
||||||
_("description"), max_length=128, null=False, blank=True
|
_("description"), max_length=128, null=False, blank=True
|
||||||
@@ -394,7 +491,7 @@ class Membership(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
return (
|
||||||
f"{self.club.name} - {self.user.username} "
|
f"{self.club.name} - {self.user.username} "
|
||||||
f"- {settings.SITH_CLUB_ROLES[self.role]} "
|
f"- {self.role.name} "
|
||||||
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -408,9 +505,6 @@ class Membership(models.Model):
|
|||||||
self._remove_club_groups([self])
|
self._remove_club_groups([self])
|
||||||
if self.end_date is None:
|
if self.end_date is None:
|
||||||
self._add_club_groups([self])
|
self._add_club_groups([self])
|
||||||
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
|
||||||
else:
|
|
||||||
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
||||||
@@ -426,12 +520,15 @@ class Membership(models.Model):
|
|||||||
if user.is_root or user.is_board_member:
|
if user.is_root or user.is_board_member:
|
||||||
return True
|
return True
|
||||||
membership = self.club.get_membership_for(user)
|
membership = self.club.get_membership_for(user)
|
||||||
return membership is not None and membership.role >= self.role
|
if not membership:
|
||||||
|
return False
|
||||||
|
return membership.user_id == user.id or (
|
||||||
|
membership.is_board and membership.role.order < self.role.order
|
||||||
|
)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self._remove_club_groups([self])
|
self._remove_club_groups([self])
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
cache.delete(f"membership_{self.club_id}_{self.user_id}")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _remove_club_groups(
|
def _remove_club_groups(
|
||||||
@@ -503,7 +600,7 @@ class Membership(models.Model):
|
|||||||
group_id=membership.club.members_group_id,
|
group_id=membership.club.members_group_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
if membership.role.is_board:
|
||||||
club_groups.append(
|
club_groups.append(
|
||||||
User.groups.through(
|
User.groups.through(
|
||||||
user_id=membership.user_id,
|
user_id=membership.user_id,
|
||||||
@@ -676,3 +773,78 @@ class MailingSubscription(models.Model):
|
|||||||
|
|
||||||
def fetch_format(self):
|
def fetch_format(self):
|
||||||
return self.get_email + " "
|
return self.get_email + " "
|
||||||
|
|
||||||
|
|
||||||
|
class LinkType(models.Model):
|
||||||
|
"""A link type, in order to group links and give them icons.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
Among all club links, there is a special one, with an empty base url
|
||||||
|
and a default link icon.
|
||||||
|
It is use as a fallback item when no actual link type can be found.
|
||||||
|
|
||||||
|
Danger:
|
||||||
|
LinkType.icon is content that will be raw-rendered in the template.
|
||||||
|
It is NOT safe to allow users to give it.
|
||||||
|
The edition of this field must be reserved to trusted admins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(_("name"), max_length=40)
|
||||||
|
url_base = models.URLField(
|
||||||
|
"url base",
|
||||||
|
unique=True,
|
||||||
|
help_text=_("The base url that links with this type must respect"),
|
||||||
|
)
|
||||||
|
icon = models.CharField(
|
||||||
|
_("icon"),
|
||||||
|
max_length=40,
|
||||||
|
help_text=_("The fontawesome class to use (e.g. `fa-brands fa-instagram`)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("link type")
|
||||||
|
verbose_name_plural = _("link types")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class ClubLink(models.Model):
|
||||||
|
link_type = models.ForeignKey(
|
||||||
|
LinkType,
|
||||||
|
verbose_name=_("link type"),
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="links",
|
||||||
|
)
|
||||||
|
name = models.CharField(_("name"), max_length=40, blank=True)
|
||||||
|
url = models.URLField(_("link url"))
|
||||||
|
club = models.ForeignKey(
|
||||||
|
Club, verbose_name=_("club"), on_delete=models.CASCADE, related_name="links"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("club link")
|
||||||
|
verbose_name_plural = _("club links")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["club", "url"],
|
||||||
|
name="club_clublink_unique_club_url",
|
||||||
|
violation_error_message=_("Duplicated url"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
if not self.name:
|
||||||
|
self.name = self.link_type.name
|
||||||
|
return super().save(**kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if not self.url.startswith(self.link_type.url_base):
|
||||||
|
raise ValidationError(
|
||||||
|
_("This link doesn't match with the url base of its type.")
|
||||||
|
)
|
||||||
|
|||||||
+39
-8
@@ -1,18 +1,18 @@
|
|||||||
|
from datetime import date
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from annotated_types import MinLen
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from ninja import Field, FilterSchema, ModelSchema
|
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||||
|
from pydantic import HttpUrl
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.schemas import SimpleUserSchema
|
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||||
|
|
||||||
|
|
||||||
class ClubSearchFilterSchema(FilterSchema):
|
class ClubSearchFilterSchema(FilterSchema):
|
||||||
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
|
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
parent_name: str | None = Field(None, q="parent__name__icontains")
|
|
||||||
exclude_ids: set[int] | None = None
|
exclude_ids: set[int] | None = None
|
||||||
|
|
||||||
def filter_exclude_ids(self, value: set[int] | None):
|
def filter_exclude_ids(self, value: set[int] | None):
|
||||||
@@ -32,7 +32,7 @@ class ClubProfileSchema(ModelSchema):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Club
|
model = Club
|
||||||
fields = ["id", "name", "logo"]
|
fields = ["id", "name", "logo", "is_active", "short_description"]
|
||||||
|
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
@@ -41,12 +41,21 @@ class ClubProfileSchema(ModelSchema):
|
|||||||
return obj.get_absolute_url()
|
return obj.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = ClubRole
|
||||||
|
fields = ["id", "name", "is_presidency", "is_board"]
|
||||||
|
|
||||||
|
|
||||||
class ClubMemberSchema(ModelSchema):
|
class ClubMemberSchema(ModelSchema):
|
||||||
|
"""A schema to represent all memberships in a club."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Membership
|
model = Membership
|
||||||
fields = ["start_date", "end_date", "role", "description"]
|
fields = ["start_date", "end_date", "description"]
|
||||||
|
|
||||||
user: SimpleUserSchema
|
user: SimpleUserSchema
|
||||||
|
role: ClubRoleSchema
|
||||||
|
|
||||||
|
|
||||||
class ClubSchema(ModelSchema):
|
class ClubSchema(ModelSchema):
|
||||||
@@ -55,3 +64,25 @@ class ClubSchema(ModelSchema):
|
|||||||
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
|
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
|
||||||
|
|
||||||
members: list[ClubMemberSchema]
|
members: list[ClubMemberSchema]
|
||||||
|
links: list[HttpUrl]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_links(obj: Club):
|
||||||
|
return [link.url for link in obj.links.all()]
|
||||||
|
|
||||||
|
|
||||||
|
class UserMembershipSchema(ModelSchema):
|
||||||
|
"""A schema to represent the active club memberships of a user."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Membership
|
||||||
|
fields = ["id", "start_date", "description"]
|
||||||
|
|
||||||
|
club: SimpleClubSchema
|
||||||
|
role: ClubRoleSchema
|
||||||
|
user: SimpleUserSchema
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipFilterSchema(FilterSchema):
|
||||||
|
since_date: Annotated[date, FilterLookup("date__lte")]
|
||||||
|
clubs_id: set[int] | None = None
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
import type { escape_html } from "tom-select/src/utils";
|
||||||
import { AjaxSelect } from "#core:core/components/ajax-select-base";
|
import { AjaxSelect } from "#core:core/components/ajax-select-base";
|
||||||
import { registerComponent } from "#core:utils/web-components";
|
import { registerComponent } from "#core:utils/web-components";
|
||||||
import type { TomOption } from "tom-select/dist/types/types";
|
|
||||||
import type { escape_html } from "tom-select/dist/types/utils";
|
|
||||||
import { type ClubSchema, clubSearchClub } from "#openapi";
|
import { type ClubSchema, clubSearchClub } from "#openapi";
|
||||||
|
|
||||||
@registerComponent("club-ajax-select")
|
@registerComponent("club-ajax-select")
|
||||||
@@ -10,7 +9,7 @@ export class ClubAjaxSelect extends AjaxSelect {
|
|||||||
protected labelField = "name";
|
protected labelField = "name";
|
||||||
protected searchField = ["code", "name"];
|
protected searchField = ["code", "name"];
|
||||||
|
|
||||||
protected async search(query: string): Promise<TomOption[]> {
|
protected async search(query: string) {
|
||||||
const resp = await clubSearchClub({ query: { search: query } });
|
const resp = await clubSearchClub({ query: { search: query } });
|
||||||
if (resp.data) {
|
if (resp.data) {
|
||||||
return resp.data.results;
|
return resp.data.results;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { AlpineComponent } from "alpinejs";
|
||||||
|
|
||||||
|
interface RoleGroupData {
|
||||||
|
isBoard: boolean;
|
||||||
|
isPresidency: boolean;
|
||||||
|
roleId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
|
||||||
|
confirmOnSubmit: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit relevant item data after it has been moved by x-sort
|
||||||
|
*/
|
||||||
|
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
|
||||||
|
item.isBoard = conf.isBoard;
|
||||||
|
item.isPresidency = conf.isPresidency;
|
||||||
|
// if the user has moved its own role outside the presidency,
|
||||||
|
// submitting the form will require a confirmation
|
||||||
|
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
|
||||||
|
this.resetOrder();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Reset the value of the ORDER input of all items in the list.
|
||||||
|
* This is to be called after any reordering operation, in order to make sure
|
||||||
|
* that the order that will be saved is coherent with what is displayed.
|
||||||
|
*/
|
||||||
|
resetOrder() {
|
||||||
|
// When moving items with x-sort, the only information we truly have is
|
||||||
|
// the end position in the target group, not the previous position nor
|
||||||
|
// the position in the global list.
|
||||||
|
// To overcome this, we loop through an enumeration of all inputs
|
||||||
|
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
|
||||||
|
const inputs = document.querySelectorAll<HTMLInputElement>(
|
||||||
|
"input[name^='roles'][name$='ORDER']",
|
||||||
|
);
|
||||||
|
for (const [i, elem] of inputs.entries()) {
|
||||||
|
elem.value = (i + 1).toString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the user moved its role out of the presidency, ask a confirmation
|
||||||
|
* before submitting the form
|
||||||
|
*/
|
||||||
|
confirmSubmission(event: SubmitEvent) {
|
||||||
|
if (
|
||||||
|
this.confirmOnSubmit &&
|
||||||
|
!confirm(
|
||||||
|
gettext(
|
||||||
|
"You're going to remove your own role from the presidency. " +
|
||||||
|
"You may lock yourself out of this page. Do you want to continue ? ",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#club-detail {
|
||||||
|
img.club-logo {
|
||||||
|
display: block;
|
||||||
|
max-height: 200px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
#club-attributes {
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .75rem;
|
||||||
|
|
||||||
|
li i {
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.has-links) {
|
||||||
|
#club-attributes {
|
||||||
|
float: right;
|
||||||
|
margin: 1em 0 1em 2em;
|
||||||
|
|
||||||
|
@media screen and (max-width: 650px) {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 400px) {
|
||||||
|
float: unset;
|
||||||
|
img.club-logo {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: 2em;
|
||||||
|
|
||||||
|
@media screen and (max-width: 650px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#club-attributes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1em;
|
||||||
|
min-width: 200px;
|
||||||
|
@media screen and (max-width: 650px) {
|
||||||
|
margin-top: 1em;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: flex-end;
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
img.club-logo {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#club-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2em;
|
||||||
|
padding: 2em;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: block;
|
||||||
|
background-color: unset;
|
||||||
|
|
||||||
|
.club-image {
|
||||||
|
float: left;
|
||||||
|
margin-right: 2rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 10%;
|
||||||
|
background-color: rgba(173, 173, 173, 0.2);
|
||||||
|
|
||||||
|
@media screen and (max-width: 500px) {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i.club-image {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 575px){
|
||||||
|
#club-list{
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.fa-grip-vertical {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
@@ -21,16 +21,43 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static("club/detail.scss") }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="club_detail">
|
<h3>{{ club.name }}</h3>
|
||||||
{% if club.logo %}
|
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
|
||||||
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
|
<div id="club-attributes">
|
||||||
{% endif %}
|
{% if club.logo %}
|
||||||
{% if page_revision %}
|
<img
|
||||||
{{ page_revision|markdown }}
|
class="club-logo"
|
||||||
{% else %}
|
src="{{ club.logo.url }}"
|
||||||
<h3>{{ club.name }}</h3>
|
alt="{{ club.name }}"
|
||||||
{% endif %}
|
width="{{ club.logo.width }}"
|
||||||
|
height="{{ club.logo.height }}"
|
||||||
|
>
|
||||||
|
{% endif %}
|
||||||
|
{% if links %}
|
||||||
|
<div id="club-links">
|
||||||
|
<h4>{% trans %}Links{% endtrans %}</h4>
|
||||||
|
<ul>
|
||||||
|
{% for link in links %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ link.url }}" rel="noopener external" target="_blank">
|
||||||
|
<i class="{{ link.link_type.icon }} fa-xl"></i>{{ link.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="club-page">
|
||||||
|
{% if page_revision %}
|
||||||
|
{{ page_revision|markdown }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,95 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% if is_fragment %}
|
||||||
|
{% extends "core/base_fragment.jinja" %}
|
||||||
|
|
||||||
{% block title -%}
|
{% block metatags %}
|
||||||
{% trans %}Club list{% endtrans %}
|
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
|
||||||
{%- endblock %}
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Liste des clubs et assos" />
|
||||||
|
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block description -%}
|
{# Don't display tabs and errors #}
|
||||||
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
|
{% block tabs %}
|
||||||
{%- endblock %}
|
{% endblock %}
|
||||||
|
{% block errors %}
|
||||||
|
{% endblock %}
|
||||||
|
{% else %}
|
||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block description -%}
|
||||||
|
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
|
||||||
|
{%- endblock %}
|
||||||
|
{% block title -%}
|
||||||
|
{% trans %}Club list{% endtrans %}
|
||||||
|
{%- endblock %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% macro display_club(club) -%}
|
{% from "core/macros.jinja" import paginate_htmx %}
|
||||||
|
|
||||||
{% if club.is_active or user.is_root %}
|
|
||||||
|
|
||||||
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
|
|
||||||
|
|
||||||
{% if not club.is_active %}
|
|
||||||
({% trans %}inactive{% endtrans %})
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
|
|
||||||
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{%- if club.children.all()|length != 0 %}
|
|
||||||
<ul>
|
|
||||||
{%- for c in club.children.order_by('name').prefetch_related("children") %}
|
|
||||||
{{ display_club(c) }}
|
|
||||||
{%- endfor %}
|
|
||||||
</ul>
|
|
||||||
{%- endif -%}
|
|
||||||
</li>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if user.is_root %}
|
<main>
|
||||||
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
|
<h3>{% trans %}Filters{% endtrans %}</h3>
|
||||||
{% endif %}
|
<form
|
||||||
{% if club_list %}
|
id="club-list-filters"
|
||||||
|
hx-get="{{ url("club:club_list") }}"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-swap="outerHtml"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
<div class="row gap-4x">
|
||||||
|
{{ form }}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-blue margin-bottom">
|
||||||
|
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<h3>{% trans %}Club list{% endtrans %}</h3>
|
<h3>{% trans %}Club list{% endtrans %}</h3>
|
||||||
<ul>
|
{% if user.has_perm("club.add_club") %}
|
||||||
{%- for club in club_list %}
|
<br>
|
||||||
{{ display_club(club) }}
|
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
|
||||||
{%- endfor %}
|
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
|
||||||
</ul>
|
</a>
|
||||||
{% else %}
|
{% endif %}
|
||||||
{% trans %}There is no club in this website.{% endtrans %}
|
<section class="aria-busy-grow" id="club-list">
|
||||||
{% endif %}
|
{% for club in object_list %}
|
||||||
|
<div class="card">
|
||||||
|
{% set club_url = club.get_absolute_url() %}
|
||||||
|
<a href="{{ club_url }}">
|
||||||
|
{% if club.logo %}
|
||||||
|
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
|
||||||
|
{% else %}
|
||||||
|
<i class="fa-regular fa-image fa-4x club-image"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="content">
|
||||||
|
<a href="{{ club_url }}">
|
||||||
|
<h4>
|
||||||
|
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
|
||||||
|
</h4>
|
||||||
|
</a>
|
||||||
|
{% set links = club.links.all() %}
|
||||||
|
{% if links %}
|
||||||
|
<br>
|
||||||
|
<div class="row gap-2x">
|
||||||
|
{% for link in club.links.all() %}
|
||||||
|
<a href="{{ link.url }}" rel="noopener external" target="_blank">
|
||||||
|
<i class="{{ link.link_type.icon }} fa-xl"></i>
|
||||||
|
<strong>{{ link.name }}</strong>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{{ club.short_description|markdown }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% if is_paginated %}
|
||||||
|
{{ paginate_htmx(request, page_obj, paginator) }}
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
|
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
|
||||||
|
|
||||||
{% block additional_js %}
|
|
||||||
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
{% block additional_css %}
|
{% block additional_css %}
|
||||||
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
|
||||||
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
|
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -16,6 +12,15 @@
|
|||||||
|
|
||||||
<h2>{% trans %}Club members{% endtrans %}</h2>
|
<h2>{% trans %}Club members{% endtrans %}</h2>
|
||||||
|
|
||||||
|
{% if club.can_roles_be_edited_by(user) %}
|
||||||
|
<a
|
||||||
|
href="{{ url("club:club_roles", club_id=object.id) }}"
|
||||||
|
class="btn btn-blue margin-bottom"
|
||||||
|
>
|
||||||
|
<i class="fa fa-users-gear"></i> {% trans %}Manage roles{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if add_member_fragment %}
|
{% if add_member_fragment %}
|
||||||
<br />
|
<br />
|
||||||
{{ add_member_fragment }}
|
{{ add_member_fragment }}
|
||||||
@@ -45,7 +50,7 @@
|
|||||||
{% for m in members %}
|
{% for m in members %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user_profile_link(m.user) }}</td>
|
<td>{{ user_profile_link(m.user) }}</td>
|
||||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
<td>{{ m.role.name }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
{%- if can_end_membership -%}
|
{%- if can_end_membership -%}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{% for member in old_members %}
|
{% for member in old_members %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user_profile_link(member.user) }}</td>
|
<td>{{ user_profile_link(member.user) }}</td>
|
||||||
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
|
<td>{{ member.role.name }}</td>
|
||||||
<td>{{ member.description }}</td>
|
<td>{{ member.description }}</td>
|
||||||
<td>{{ member.start_date }}</td>
|
<td>{{ member.start_date }}</td>
|
||||||
<td>{{ member.end_date }}</td>
|
<td>{{ member.end_date }}</td>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% block additional_js %}
|
||||||
|
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}" xmlns="http://www.w3.org/1999/html"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% macro display_subform(subform) %}
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
x-data="{
|
||||||
|
isPresidency: {{ subform.is_presidency.value()|lower }},
|
||||||
|
isBoard: {{ subform.is_board.value()|lower }},
|
||||||
|
roleId: {{ subform.id.value() }},
|
||||||
|
}"
|
||||||
|
x-sort:item="$data"
|
||||||
|
>
|
||||||
|
{# hidden fields #}
|
||||||
|
{{ subform.ORDER }}
|
||||||
|
{{ subform.id }}
|
||||||
|
{{ subform.club }}
|
||||||
|
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
|
||||||
|
{{ subform.is_board|add_attr("x-model=isBoard") }}
|
||||||
|
<i class="fa fa-grip-vertical" x-sort:handle></i>
|
||||||
|
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
|
||||||
|
<summary>
|
||||||
|
{{ subform.name.value() }}
|
||||||
|
{% if not subform.instance.is_active -%}
|
||||||
|
({% trans %}inactive{% endtrans %})
|
||||||
|
{%- endif %}
|
||||||
|
</summary>
|
||||||
|
<div class="accordion-content">
|
||||||
|
{{ subform.non_field_errors() }}
|
||||||
|
<div class="form-group">
|
||||||
|
{{ subform.name.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ subform.description.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div>
|
||||||
|
{{ subform.is_active }}
|
||||||
|
{{ subform.is_active.label_tag() }}
|
||||||
|
</div>
|
||||||
|
<span class="helptext">
|
||||||
|
{{ subform.is_active.help_text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>
|
||||||
|
{% trans trimmed %}
|
||||||
|
Roles give rights on the club.
|
||||||
|
Higher roles grant more rights, and the members having them are displayed higher
|
||||||
|
in the club members list.
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans trimmed %}
|
||||||
|
On this page, you can edit their name and description, as well as their order.
|
||||||
|
You can also drag roles from a category to another
|
||||||
|
(e.g. a board role can be made into a presidency role).
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
|
||||||
|
@submit="confirmSubmission"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.management_form }}
|
||||||
|
{{ form.non_form_errors() }}
|
||||||
|
<h3>{% trans %}Presidency{% endtrans %}</h3>
|
||||||
|
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
|
||||||
|
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<details class="clickable margin-bottom">
|
||||||
|
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||||
|
{# The style we use for markdown rendering is quite nice for what we want to display,
|
||||||
|
so we are just gonna reuse it. #}
|
||||||
|
<div class="markdown">
|
||||||
|
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
|
||||||
|
<li>{% trans %}manage the club counters{% endtrans %}</li>
|
||||||
|
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
|
||||||
|
</ul>
|
||||||
|
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div
|
||||||
|
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
|
||||||
|
x-sort:group="roles"
|
||||||
|
>
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if subform.is_presidency.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<h3>{% trans %}Board{% endtrans %}</h3>
|
||||||
|
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
|
||||||
|
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<details class="clickable margin-bottom">
|
||||||
|
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||||
|
<div class="markdown">
|
||||||
|
<p>
|
||||||
|
{% trans trimmed %}
|
||||||
|
Board members can do most administrative actions in the club, including :
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans %}manage the club posters{% endtrans %}</li>
|
||||||
|
<li>{% trans %}create news for the club{% endtrans %}</li>
|
||||||
|
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
|
||||||
|
<li>
|
||||||
|
{% trans trimmed %}
|
||||||
|
add new members and end active memberships
|
||||||
|
for roles that are lower than their own.
|
||||||
|
{% endtrans %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div
|
||||||
|
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
|
||||||
|
x-sort:group="roles"
|
||||||
|
>
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if subform.is_board.value() and not subform.is_presidency.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<h3>{% trans %}Members{% endtrans %}</h3>
|
||||||
|
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
|
||||||
|
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<details class="clickable margin-bottom">
|
||||||
|
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||||
|
<div class="markdown">
|
||||||
|
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div
|
||||||
|
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
|
||||||
|
x-sort:group="roles"
|
||||||
|
>
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if not subform.is_board.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
<button type="submit" class="btn btn-blue">
|
||||||
|
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
@@ -35,7 +35,7 @@ TODO : rewrite the pagination used in this template an Alpine one
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form }}
|
{{ form }}
|
||||||
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
|
<p><input type="submit" value="{% trans %}Show{% endtrans %}" /></p>
|
||||||
<p><input type="submit" value="{% trans %}Download as cvs{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
|
<p><input type="submit" value="{% trans %}Download as CSV{% endtrans %}" formaction="{{ url('club:sellings_csv', club_id=object.id) }}"/></p>
|
||||||
</form>
|
</form>
|
||||||
<p>
|
<p>
|
||||||
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/>
|
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}<br/>
|
||||||
|
|||||||
@@ -5,8 +5,19 @@
|
|||||||
<div>
|
<div>
|
||||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
|
<li>
|
||||||
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
|
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
|
||||||
|
{% trans %}Create a news{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
|
||||||
|
{% trans %}Post in the Weekmail{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if object.can_roles_be_edited_by(user) %}
|
||||||
|
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
|
||||||
|
{% endif %}
|
||||||
{% if object.trombi %}
|
{% if object.trombi %}
|
||||||
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -1,9 +1,63 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% block additional_js %}
|
||||||
|
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans name=object %}Edit {{ name }}{% endtrans %}
|
{% trans name=object %}Edit {{ name }}{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% macro link_form(form) %}
|
||||||
|
<fieldset
|
||||||
|
{# set url in x-init rather than in x-data,
|
||||||
|
in order to trigger the $watch on initial load #}
|
||||||
|
x-data="{ url: '', linkType: { icon: '', id: 0 } }"
|
||||||
|
x-init="() => {
|
||||||
|
$watch('url', (u) => linkType = linkTypes.find((t) => u.startsWith(t.url)));
|
||||||
|
url = '{{ form.url.value() or "" }}';
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ form.non_field_errors() }}
|
||||||
|
<div class="form-group row gap-2x">
|
||||||
|
<div>
|
||||||
|
{{ form.url.label_tag() }}
|
||||||
|
{{ form.url.errors }}
|
||||||
|
<span>
|
||||||
|
{# we change the icon when the user change it and leave the input,
|
||||||
|
or when it is pasted from the clipboard #}
|
||||||
|
{{ form.url|add_attr("x-model.change=url,@paste.prevent=url = $event.clipboardData.getData('text')") }}
|
||||||
|
<i
|
||||||
|
:class="linkType.icon || 'fa fa-link'"
|
||||||
|
tooltip="{% trans %}This icon will change according to the given url.{% endtrans %}"
|
||||||
|
></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>{{ form.name.as_field_group() }}</div>
|
||||||
|
</div>
|
||||||
|
{%- if form.DELETE -%}
|
||||||
|
<div class="form-group row gap">
|
||||||
|
{{ form.DELETE.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
{%- else -%}
|
||||||
|
<br>
|
||||||
|
<button
|
||||||
|
class="btn btn-grey"
|
||||||
|
@click.prevent="removeForm($event.target.closest('fieldset'))"
|
||||||
|
>
|
||||||
|
<i class="fa fa-minus"></i> {% trans %}Remove link{% endtrans %}
|
||||||
|
</button>
|
||||||
|
{%- endif -%}
|
||||||
|
{{ form.link_type|add_attr(":value=linkType.id") }}
|
||||||
|
{%- for field in form.hidden_fields() -%}
|
||||||
|
{%- if field != form.link_type -%}
|
||||||
|
{{ field }}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
</fieldset>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
|
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
|
||||||
|
|
||||||
@@ -17,7 +71,7 @@
|
|||||||
and explicitly separate them from the non-admin ones,
|
and explicitly separate them from the non-admin ones,
|
||||||
with some help text.
|
with some help text.
|
||||||
Non-admin users will only see the regular form fields,
|
Non-admin users will only see the regular form fields,
|
||||||
so they don't need thoses explanations #}
|
so they don't need those explanations #}
|
||||||
<h3>{% trans %}Club properties{% endtrans %}</h3>
|
<h3>{% trans %}Club properties{% endtrans %}</h3>
|
||||||
<p class="helptext">
|
<p class="helptext">
|
||||||
{% trans trimmed %}
|
{% trans trimmed %}
|
||||||
@@ -25,7 +79,7 @@
|
|||||||
Only admin users can see and edit them.
|
Only admin users can see and edit them.
|
||||||
{% endtrans %}
|
{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
<fieldset class="required margin-bottom">
|
<fieldset class="margin-bottom">
|
||||||
{% for field_name in form.admin_fields %}
|
{% for field_name in form.admin_fields %}
|
||||||
{% set field = form[field_name] %}
|
{% set field = form[field_name] %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -36,11 +90,13 @@
|
|||||||
{# Remove the the admin fields from the form.
|
{# Remove the the admin fields from the form.
|
||||||
The remaining non-admin fields will be rendered
|
The remaining non-admin fields will be rendered
|
||||||
at once with a simple {{ form.as_p() }} #}
|
at once with a simple {{ form.as_p() }} #}
|
||||||
{% set _ = form.fields.pop(field_name) %}
|
{% do form.fields.pop(field_name) %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h3>{% trans %}Club informations{% endtrans %}</h3>
|
<h3>{% trans %}Club informations{% endtrans %}</h3>
|
||||||
|
{% if form.admin_fields %}
|
||||||
<p class="helptext">
|
<p class="helptext">
|
||||||
{% trans trimmed %}
|
{% trans trimmed %}
|
||||||
The following form fields are linked to the basic description of a club.
|
The following form fields are linked to the basic description of a club.
|
||||||
@@ -48,7 +104,45 @@
|
|||||||
{% endtrans %}
|
{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ form.as_p() }}
|
<fieldset class="margin-bottom">
|
||||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
{{ form.as_p() }}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h3>{% trans %}Club links{% endtrans %}</h3>
|
||||||
|
<div x-data="dynamicFormSet({ prefix: '{{ form.link_formset.prefix }}' })" class="margin-bottom">
|
||||||
|
{{ form.link_formset.management_form }}
|
||||||
|
<div x-ref="formContainer">
|
||||||
|
{%- for f in form.link_formset.forms -%}
|
||||||
|
{{ link_form(f) }}
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
<template x-ref="formTemplate">
|
||||||
|
{{ link_form(form.link_formset.empty_form) }}
|
||||||
|
</template>
|
||||||
|
<p>
|
||||||
|
<i>{% trans trimmed %}
|
||||||
|
Note: if the icon of one of your links doesn't exist yet,
|
||||||
|
you can ask the info team to add it.
|
||||||
|
{% endtrans %}</i>
|
||||||
|
</p>
|
||||||
|
<br>
|
||||||
|
<button @click.prevent="addForm()" class="btn btn-grey">
|
||||||
|
<i class="fa fa-plus"></i>{% trans %}Add link{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<button type="submit" class="btn btn-blue">
|
||||||
|
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
const linkTypes = [
|
||||||
|
{%- for t in link_types -%}
|
||||||
|
{ id: {{ t.id }}, url: '{{ t.url_base }}', icon: '{{ t.icon }}' },
|
||||||
|
{%- endfor -%}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
{% from 'core/macros_pages.jinja' import page_history %}
|
{% from 'core/page/macros.jinja' import page_history %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if club.page %}
|
{{ page_history(club.page) }}
|
||||||
{{ page_history(club.page) }}
|
|
||||||
{% else %}
|
|
||||||
{% trans %}No page existing for this club{% endtrans %}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
{% from 'core/macros_pages.jinja' import page_edit_form %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
|
<h2>{% trans %}Edit page{% endtrans %}</h2>
|
||||||
|
<form action="{{ url('club:club_edit_page', club_id=page.club.id) }}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p() }}
|
||||||
|
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||||
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-5
@@ -8,7 +8,7 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
@@ -43,6 +43,11 @@ class TestClub(TestCase):
|
|||||||
|
|
||||||
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
|
cls.president_role = baker.make(
|
||||||
|
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
|
||||||
|
)
|
||||||
|
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
|
||||||
|
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
|
||||||
cls.new_members_url = reverse(
|
cls.new_members_url = reverse(
|
||||||
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
||||||
)
|
)
|
||||||
@@ -51,12 +56,17 @@ class TestClub(TestCase):
|
|||||||
yesterday = now() - timedelta(days=1)
|
yesterday = now() - timedelta(days=1)
|
||||||
membership_recipe = Recipe(Membership, club=cls.club)
|
membership_recipe = Recipe(Membership, club=cls.club)
|
||||||
membership_recipe.make(
|
membership_recipe.make(
|
||||||
user=cls.simple_board_member, start_date=a_month_ago, role=3
|
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
|
||||||
|
)
|
||||||
|
membership_recipe.make(user=cls.richard, role=cls.member_role)
|
||||||
|
membership_recipe.make(
|
||||||
|
user=cls.president, start_date=a_month_ago, role=cls.president_role
|
||||||
)
|
)
|
||||||
membership_recipe.make(user=cls.richard, role=1)
|
|
||||||
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
|
|
||||||
membership_recipe.make( # sli was a member but isn't anymore
|
membership_recipe.make( # sli was a member but isn't anymore
|
||||||
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
|
user=cls.sli,
|
||||||
|
start_date=a_month_ago,
|
||||||
|
end_date=yesterday,
|
||||||
|
role=cls.board_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
+105
-5
@@ -1,12 +1,18 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import ProgrammingError
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -16,12 +22,106 @@ def test_club_queryset_having_board_member():
|
|||||||
membership_recipe = Recipe(
|
membership_recipe = Recipe(
|
||||||
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
||||||
)
|
)
|
||||||
membership_recipe.make(club=clubs[0], role=1)
|
|
||||||
membership_recipe.make(club=clubs[1], role=3)
|
|
||||||
membership_recipe.make(club=clubs[2], role=7)
|
|
||||||
membership_recipe.make(
|
membership_recipe.make(
|
||||||
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
|
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
|
||||||
|
)
|
||||||
|
membership_recipe.make(
|
||||||
|
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
|
||||||
|
)
|
||||||
|
membership_recipe.make(
|
||||||
|
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
|
||||||
|
)
|
||||||
|
membership_recipe.make(
|
||||||
|
club=clubs[3],
|
||||||
|
role=baker.make(ClubRole, club=clubs[3], is_board=True),
|
||||||
|
end_date=localdate() - timedelta(days=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
||||||
assert set(club_ids) == {clubs[1].id, clubs[2].id}
|
assert set(club_ids) == {clubs[1].id, clubs[2].id}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
|
||||||
|
@pytest.mark.parametrize("is_fragment", [True, False])
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
|
||||||
|
client.force_login(baker.make(User))
|
||||||
|
baker.make(Club, _quantity=nb_additional_clubs)
|
||||||
|
headers = {"HX-Request": True} if is_fragment else {}
|
||||||
|
res = client.get(reverse("club:club_list"), headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def assert_club_created(club_name: str):
|
||||||
|
club = Club.objects.last()
|
||||||
|
assert club.name == club_name
|
||||||
|
assert club.board_group.name == f"{club_name} - Bureau"
|
||||||
|
assert club.members_group.name == f"{club_name} - Membres"
|
||||||
|
# default roles should be added on club creation,
|
||||||
|
# whether the creation happens on the admin site or on the user site
|
||||||
|
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
|
||||||
|
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
|
||||||
|
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
|
||||||
|
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
|
||||||
|
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_view(admin_client: Client):
|
||||||
|
"""Test that the club creation view works well"""
|
||||||
|
res = admin_client.get(reverse("club:club_new"))
|
||||||
|
assert res.status_code == 200
|
||||||
|
res = admin_client.post(
|
||||||
|
reverse("club:club_new"),
|
||||||
|
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
|
||||||
|
)
|
||||||
|
club = Club.objects.last()
|
||||||
|
assertRedirects(res, club.get_absolute_url())
|
||||||
|
assert_club_created("foo")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_default_roles_for_club_with_roles_fails():
|
||||||
|
"""Test that an Error is raised if trying to create
|
||||||
|
default roles for a club that already has roles.
|
||||||
|
"""
|
||||||
|
club = baker.make(Club)
|
||||||
|
baker.make(ClubRole, club=club)
|
||||||
|
with pytest.raises(ProgrammingError):
|
||||||
|
club.create_default_roles()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestAdminInterface:
|
||||||
|
def test_create(self, admin_client: Client):
|
||||||
|
"""Test the creation of a club via the admin interface."""
|
||||||
|
res = admin_client.post(
|
||||||
|
reverse("admin:club_club_add"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
"parent": settings.SITH_MAIN_CLUB_ID,
|
||||||
|
"address": "Rome",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||||
|
assert_club_created("foo")
|
||||||
|
|
||||||
|
def test_change(self, admin_client: Client):
|
||||||
|
"""Test the edition of a club via the admin interface."""
|
||||||
|
club = baker.make(Club)
|
||||||
|
res = admin_client.post(
|
||||||
|
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
"page": club.page_id,
|
||||||
|
"home": club.home_id,
|
||||||
|
"address": club.address,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||||
|
club.refresh_from_db()
|
||||||
|
assert club.name == "foo"
|
||||||
|
# Club roles shouldn't be modified when editing the club on the admin interface
|
||||||
|
# This club had no roles beforehand, therefore it shouldn't have roles now.
|
||||||
|
assert not club.roles.exists()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -8,7 +9,7 @@ from model_bakery import baker
|
|||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertNumQueries
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, Page, User
|
from core.models import Group, Page, User
|
||||||
|
|
||||||
@@ -26,8 +27,10 @@ class TestClubSearch(TestCase):
|
|||||||
"id", flat=True
|
"id", flat=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Page.objects.exclude(club=None).delete()
|
Membership.objects.all().delete()
|
||||||
|
ClubRole.objects.all().delete()
|
||||||
Club.objects.all().delete()
|
Club.objects.all().delete()
|
||||||
|
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
|
||||||
Group.objects.filter(id__in=groups).delete()
|
Group.objects.filter(id__in=groups).delete()
|
||||||
|
|
||||||
cls.clubs = baker.make(
|
cls.clubs = baker.make(
|
||||||
@@ -85,8 +88,8 @@ class TestFetchClub:
|
|||||||
def test_fetch_club_nb_queries(self, client: Client, club: Club):
|
def test_fetch_club_nb_queries(self, client: Client, club: Club):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
with assertNumQueries(6):
|
with assertNumQueries(7):
|
||||||
# - 4 queries for authentication
|
# - 4 queries for authentication
|
||||||
# - 2 queries for the actual data
|
# - 3 queries for the actual data
|
||||||
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import localdate
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from club.models import Club, ClubRole, Membership
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class TestMembershipAPI(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = baker.make(User)
|
||||||
|
perm = Permission.objects.get(codename="view_club")
|
||||||
|
cls.user.user_permissions.add(perm)
|
||||||
|
cls.clubs = baker.make(Club, _quantity=3, is_active=True)
|
||||||
|
cls.roles = baker.make(ClubRole, _quantity=3, is_active=True)
|
||||||
|
cls.expectedNumQueries = 5
|
||||||
|
|
||||||
|
# Clean existing data to avoid side effects
|
||||||
|
Membership.objects.all().delete()
|
||||||
|
|
||||||
|
cls.memberships = [
|
||||||
|
# on going
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[0],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[0],
|
||||||
|
start_date=localdate() - timedelta(weeks=1),
|
||||||
|
),
|
||||||
|
# on going
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[1],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[1],
|
||||||
|
start_date=localdate() - timedelta(days=1),
|
||||||
|
),
|
||||||
|
# former
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[1],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[2],
|
||||||
|
start_date=localdate() - timedelta(weeks=2),
|
||||||
|
end_date=localdate() - timedelta(days=6),
|
||||||
|
),
|
||||||
|
# on going
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[2],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[0],
|
||||||
|
start_date=localdate() - timedelta(weeks=3),
|
||||||
|
),
|
||||||
|
# former
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[1],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[2],
|
||||||
|
start_date=localdate() - timedelta(days=4),
|
||||||
|
end_date=localdate() - timedelta(days=3),
|
||||||
|
),
|
||||||
|
# on going
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[2],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[0],
|
||||||
|
start_date=localdate() - timedelta(days=1),
|
||||||
|
),
|
||||||
|
# former
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[0],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[0],
|
||||||
|
start_date=localdate() - timedelta(weeks=6),
|
||||||
|
end_date=localdate() - timedelta(days=3),
|
||||||
|
),
|
||||||
|
# former
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[2],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[0],
|
||||||
|
start_date=localdate() - timedelta(weeks=8),
|
||||||
|
end_date=localdate() - timedelta(days=6),
|
||||||
|
),
|
||||||
|
# former
|
||||||
|
Membership.objects.create(
|
||||||
|
club=cls.clubs[1],
|
||||||
|
user=subscriber_user.make(),
|
||||||
|
role=cls.roles[0],
|
||||||
|
start_date=localdate() - timedelta(weeks=8),
|
||||||
|
end_date=localdate() - timedelta(weeks=7, days=5),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewMembershipAPI(TestMembershipAPI):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
cls.url = reverse("api:get_new_clubs_members_since_date")
|
||||||
|
|
||||||
|
def test_new_membership_one_club(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
since_date = localdate() - timedelta(weeks=1)
|
||||||
|
arg = {"since_date": since_date, "clubs_id": self.clubs[0].id}
|
||||||
|
with self.assertNumQueries(self.expectedNumQueries):
|
||||||
|
response = self.client.get(self.url, query_params=arg)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
membership_ids = [e["id"] for e in data]
|
||||||
|
expected_ids = [self.memberships[0].id]
|
||||||
|
assert membership_ids == expected_ids
|
||||||
|
|
||||||
|
def test_new_membership_multiple_club(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
since_date = localdate() - timedelta(weeks=1)
|
||||||
|
arg = {
|
||||||
|
"since_date": since_date,
|
||||||
|
"clubs_id": [self.clubs[0].id, self.clubs[1].id],
|
||||||
|
}
|
||||||
|
with self.assertNumQueries(self.expectedNumQueries):
|
||||||
|
response = self.client.get(self.url, query_params=arg)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
membership_ids = [e["id"] for e in data]
|
||||||
|
expected_ids = [self.memberships[0].id, self.memberships[1].id]
|
||||||
|
assert membership_ids == expected_ids
|
||||||
|
|
||||||
|
def test_new_membership_all_clubs(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
since_date = localdate() - timedelta(weeks=1)
|
||||||
|
arg = {"since_date": since_date}
|
||||||
|
with self.assertNumQueries(self.expectedNumQueries):
|
||||||
|
response = self.client.get(self.url, query_params=arg)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
membership_ids = [e["id"] for e in data]
|
||||||
|
expected_ids = [
|
||||||
|
self.memberships[0].id,
|
||||||
|
self.memberships[1].id,
|
||||||
|
self.memberships[5].id,
|
||||||
|
]
|
||||||
|
assert membership_ids == expected_ids
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormerMembershipAPI(TestMembershipAPI):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
cls.url = reverse("api:get_former_clubs_members_since_date")
|
||||||
|
|
||||||
|
def test_former_membership_one_club(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
since_date = localdate() - timedelta(weeks=1)
|
||||||
|
arg = {"since_date": since_date, "clubs_id": self.clubs[1].id}
|
||||||
|
with self.assertNumQueries(self.expectedNumQueries):
|
||||||
|
response = self.client.get(self.url, query_params=arg)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
membership_ids = [e["id"] for e in data]
|
||||||
|
expected_ids = [self.memberships[2].id]
|
||||||
|
assert membership_ids == expected_ids
|
||||||
|
|
||||||
|
def test_new_membership_multiple_club(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
since_date = localdate() - timedelta(weeks=1)
|
||||||
|
arg = {
|
||||||
|
"since_date": since_date,
|
||||||
|
"clubs_id": [self.clubs[1].id, self.clubs[0].id],
|
||||||
|
}
|
||||||
|
with self.assertNumQueries(self.expectedNumQueries):
|
||||||
|
response = self.client.get(self.url, query_params=arg)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
membership_ids = [e["id"] for e in data]
|
||||||
|
expected_ids = [self.memberships[6].id, self.memberships[2].id]
|
||||||
|
assert membership_ids == expected_ids
|
||||||
|
|
||||||
|
def test_new_membership_all_clubs(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
since_date = localdate() - timedelta(weeks=1)
|
||||||
|
arg = {"since_date": since_date}
|
||||||
|
with self.assertNumQueries(self.expectedNumQueries):
|
||||||
|
response = self.client.get(self.url, query_params=arg)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
membership_ids = [e["id"] for e in data]
|
||||||
|
expected_ids = [
|
||||||
|
self.memberships[7].id,
|
||||||
|
self.memberships[6].id,
|
||||||
|
self.memberships[2].id,
|
||||||
|
]
|
||||||
|
assert membership_ids == expected_ids
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from model_bakery import baker, seq
|
||||||
|
from model_bakery.recipe import Recipe
|
||||||
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
|
from club.forms import ClubRoleFormSet
|
||||||
|
from club.models import Club, ClubRole, Membership
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import AnonymousUser, User
|
||||||
|
|
||||||
|
|
||||||
|
def make_club():
|
||||||
|
# unittest-style tests cannot use fixture, so we create a function
|
||||||
|
# that will be callable either by a pytest fixture or inside
|
||||||
|
# a TestCase.setUpTestData method.
|
||||||
|
club = baker.make(Club)
|
||||||
|
recipe = Recipe(ClubRole, club=club, name=seq("role "))
|
||||||
|
recipe.make(
|
||||||
|
is_board=iter([True, True, False]),
|
||||||
|
is_presidency=iter([True, False, False]),
|
||||||
|
order=iter([0, 1, 2]),
|
||||||
|
_quantity=3,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
return club
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def club(db):
|
||||||
|
"""A club with a presidency role, a board role and a member role"""
|
||||||
|
return make_club()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_order_auto(club):
|
||||||
|
"""Test that newly created roles are put in the right place."""
|
||||||
|
roles = list(club.roles.all())
|
||||||
|
# create new roles one by one (like they will be in prod)
|
||||||
|
# each new role should be placed at the end of its category
|
||||||
|
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
|
||||||
|
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
|
||||||
|
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
|
||||||
|
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
|
||||||
|
assert list(club.roles.order_by("order")) == [
|
||||||
|
roles[0],
|
||||||
|
role_a,
|
||||||
|
roles[1],
|
||||||
|
role_b,
|
||||||
|
roles[2],
|
||||||
|
role_c,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("user_factory", "is_allowed"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
lambda club: baker.make(
|
||||||
|
User,
|
||||||
|
user_permissions=[Permission.objects.get(codename="change_clubrole")],
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
( # user with presidency roles can edit the club roles
|
||||||
|
lambda club: subscriber_user.make(
|
||||||
|
memberships=[
|
||||||
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
role=club.roles.filter(is_presidency=True).first(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
( # user in the board but not in the presidency cannot edit roles
|
||||||
|
lambda club: subscriber_user.make(
|
||||||
|
memberships=[
|
||||||
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
role=club.roles.filter(
|
||||||
|
is_presidency=False, is_board=True
|
||||||
|
).first(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(lambda _: AnonymousUser(), False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_can_roles_be_edited_by(
|
||||||
|
club: Club, user_factory: Callable[[Club], User], is_allowed
|
||||||
|
):
|
||||||
|
"""Test that `Club.can_roles_be_edited_by` return the right value"""
|
||||||
|
user = user_factory(club)
|
||||||
|
assert club.can_roles_be_edited_by(user) == is_allowed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["route", "is_presidency", "is_board"],
|
||||||
|
[
|
||||||
|
("club:new_role_president", True, True),
|
||||||
|
("club:new_role_board", False, True),
|
||||||
|
("club:new_role_member", False, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
|
||||||
|
"""Test that the role creation views work."""
|
||||||
|
club = baker.make(Club)
|
||||||
|
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
|
||||||
|
user = subscriber_user.make()
|
||||||
|
baker.make(Membership, club=club, role=role, user=user, end_date=None)
|
||||||
|
url = reverse(route, kwargs={"club_id": club.id})
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
res = client.get(url)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
res = client.post(url, data={"name": "foo"})
|
||||||
|
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
|
||||||
|
new_role = club.roles.last()
|
||||||
|
assert new_role.name == "foo"
|
||||||
|
assert new_role.is_presidency == is_presidency
|
||||||
|
assert new_role.is_board == is_board
|
||||||
|
|
||||||
|
|
||||||
|
class TestClubRoleUpdate(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = make_club()
|
||||||
|
cls.roles = list(cls.club.roles.all())
|
||||||
|
cls.user = subscriber_user.make()
|
||||||
|
baker.make(
|
||||||
|
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
|
||||||
|
)
|
||||||
|
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
|
||||||
|
cls.redirect_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.payload = {
|
||||||
|
"roles-TOTAL_FORMS": 3,
|
||||||
|
"roles-INITIAL_FORMS": 3,
|
||||||
|
"roles-MIN_NUM_FORMS": 0,
|
||||||
|
"roles-MAX_NUM_FORMS": 1000,
|
||||||
|
"roles-0-ORDER": 1,
|
||||||
|
"roles-0-id": self.roles[0].id,
|
||||||
|
"roles-0-club": self.club.id,
|
||||||
|
"roles-0-is_presidency": True,
|
||||||
|
"roles-0-is_board": True,
|
||||||
|
"roles-0-name": self.roles[0].name,
|
||||||
|
"roles-0-description": self.roles[0].description,
|
||||||
|
"roles-0-is_active": True,
|
||||||
|
"roles-1-ORDER": 2,
|
||||||
|
"roles-1-id": self.roles[1].id,
|
||||||
|
"roles-1-club": self.club.id,
|
||||||
|
"roles-1-is_presidency": False,
|
||||||
|
"roles-1-is_board": True,
|
||||||
|
"roles-1-name": self.roles[1].name,
|
||||||
|
"roles-1-description": self.roles[1].description,
|
||||||
|
"roles-1-is_active": True,
|
||||||
|
"roles-2-ORDER": 3,
|
||||||
|
"roles-2-id": self.roles[2].id,
|
||||||
|
"roles-2-club": self.club.id,
|
||||||
|
"roles-2-is_presidency": False,
|
||||||
|
"roles-2-is_board": False,
|
||||||
|
"roles-2-name": self.roles[2].name,
|
||||||
|
"roles-2-description": self.roles[2].description,
|
||||||
|
"roles-2-is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_view_ok(self):
|
||||||
|
"""Basic test to check that the view works."""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
res = self.client.get(self.url)
|
||||||
|
assert res.status_code == 200
|
||||||
|
self.payload["roles-2-name"] = "foo"
|
||||||
|
res = self.client.post(self.url, data=self.payload)
|
||||||
|
assertRedirects(res, self.redirect_url)
|
||||||
|
self.roles[2].refresh_from_db()
|
||||||
|
assert self.roles[2].name == "foo"
|
||||||
|
|
||||||
|
def test_incoherent_order(self):
|
||||||
|
"""Test that placing a member role over a board role fails."""
|
||||||
|
self.payload["roles-0-ORDER"] = 4
|
||||||
|
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||||
|
assert not formset.is_valid()
|
||||||
|
assert formset.errors == [
|
||||||
|
{
|
||||||
|
"__all__": [
|
||||||
|
f"Le rôle {self.roles[0].name} ne peut pas "
|
||||||
|
"être placé en-dessous d'un rôle de membre.",
|
||||||
|
f"Le rôle {self.roles[0].name} ne peut pas être placé "
|
||||||
|
"en-dessous d'un rôle qui n'est pas de la présidence.",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_change_order_ok(self):
|
||||||
|
"""Test that changing order the intended way works"""
|
||||||
|
self.payload["roles-1-ORDER"] = 3
|
||||||
|
self.payload["roles-1-is_board"] = False
|
||||||
|
self.payload["roles-2-ORDER"] = 2
|
||||||
|
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||||
|
assert formset.is_valid()
|
||||||
|
formset.save()
|
||||||
|
assert list(self.club.roles.order_by("order")) == [
|
||||||
|
self.roles[0],
|
||||||
|
self.roles[2],
|
||||||
|
self.roles[1],
|
||||||
|
]
|
||||||
|
self.roles[1].refresh_from_db()
|
||||||
|
assert not self.roles[1].is_board
|
||||||
|
|
||||||
|
def test_non_board_presidency_is_forbidden(self):
|
||||||
|
"""Test that a role cannot be in the presidency without being in the board."""
|
||||||
|
self.payload["roles-0-is_board"] = False
|
||||||
|
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||||
|
assert not formset.is_valid()
|
||||||
|
assert formset.errors == [
|
||||||
|
{
|
||||||
|
"__all__": [
|
||||||
|
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_president_moves_itself_out_of_the_presidency(self):
|
||||||
|
"""Test that if the user moves its own role out of the presidency,
|
||||||
|
then it loses access to the update page."""
|
||||||
|
self.payload["roles-0-is_presidency"] = False
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
res = self.client.post(self.url, data=self.payload)
|
||||||
|
assertRedirects(res, self.redirect_url)
|
||||||
|
# When the user clicked that button, it still had the right to update roles,
|
||||||
|
# so the modification should be applied
|
||||||
|
self.roles[0].refresh_from_db()
|
||||||
|
assert self.roles[0].is_presidency is False
|
||||||
|
|
||||||
|
res = self.client.get(self.url)
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
def test_role_stops_being_board(self):
|
||||||
|
"""Test that if a role stops being a board role,
|
||||||
|
its users lose the club board group."""
|
||||||
|
self.payload["roles-0-is_board"] = False
|
||||||
|
self.payload["roles-0-is_presidency"] = False
|
||||||
|
self.payload["roles-1-is_board"] = False
|
||||||
|
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||||
|
assert formset.is_valid()
|
||||||
|
formset.save()
|
||||||
|
assert not self.user.groups.contains(self.club.board_group)
|
||||||
|
|
||||||
|
def test_role_becomes_board(self):
|
||||||
|
"""Test that if a role becomes a board role,
|
||||||
|
its active users get the club board group"""
|
||||||
|
members = [
|
||||||
|
baker.make(Membership, club=self.club, role=self.roles[0], end_date=None),
|
||||||
|
baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()),
|
||||||
|
]
|
||||||
|
self.payload["roles-2-is_board"] = True
|
||||||
|
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||||
|
assert formset.is_valid()
|
||||||
|
formset.save()
|
||||||
|
# the second membership is finished, so its user shouldn't get the role
|
||||||
|
assert members[0].user.groups.contains(self.club.board_group)
|
||||||
|
assert not members[1].user.groups.contains(self.club.board_group)
|
||||||
+20
-4
@@ -4,7 +4,7 @@ from django.urls import reverse
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
|
|
||||||
|
|
||||||
@@ -12,11 +12,22 @@ from core.baker_recipes import subscriber_user
|
|||||||
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
||||||
baker.make(Membership, club=club, user=user, role=7)
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
user=user,
|
||||||
|
role=baker.make(ClubRole, club=club, is_board=True),
|
||||||
|
)
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.post(
|
res = client.post(
|
||||||
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
||||||
{"name": "new name", "is_active": False, "address": "new address"},
|
{
|
||||||
|
"name": "new name",
|
||||||
|
"is_active": False,
|
||||||
|
"address": "new address",
|
||||||
|
"link-TOTAL_FORMS": 0,
|
||||||
|
"link-INITIAL_FORMS": 0,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
# The request should success,
|
# The request should success,
|
||||||
# but admin-only fields shouldn't be taken into account
|
# but admin-only fields shouldn't be taken into account
|
||||||
@@ -32,7 +43,12 @@ def test_edit_club_page_doesnt_crash(client: Client):
|
|||||||
"""crash test for club:club_edit"""
|
"""crash test for club:club_edit"""
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, club=club, user=user, role=3)
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
user=user,
|
||||||
|
role=baker.make(ClubRole, club=club, is_board=True),
|
||||||
|
)
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
from club.forms import MailingForm
|
from club.forms import MailingForm
|
||||||
from club.models import Club, Mailing, Membership
|
from club.models import Club, ClubRole, Mailing, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class TestMailingForm(TestCase):
|
|||||||
user=cls.rbatsbak,
|
user=cls.rbatsbak,
|
||||||
club=cls.club,
|
club=cls.club,
|
||||||
start_date=timezone.now(),
|
start_date=timezone.now(),
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
role=baker.make(ClubRole, club=cls.club, is_board=True),
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
def test_mailing_list_add_no_moderation(self):
|
def test_mailing_list_add_no_moderation(self):
|
||||||
|
|||||||
+200
-94
@@ -1,20 +1,20 @@
|
|||||||
|
import itertools
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.test import TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localdate, localtime, now
|
from django.utils.timezone import localdate, localtime, now
|
||||||
from model_bakery import baker
|
from model_bakery import baker, seq
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.forms import ClubAddMemberForm, JoinClubForm
|
from club.forms import ClubAddMemberForm, JoinClubForm
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from club.tests.base import TestClub
|
from club.tests.base import TestClub
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import AnonymousUser, User
|
from core.models import AnonymousUser, User
|
||||||
@@ -72,39 +72,25 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
expected.sort(key=lambda i: i.id)
|
expected.sort(key=lambda i: i.id)
|
||||||
assert members == expected
|
assert members == expected
|
||||||
|
|
||||||
def test_update_invalidate_cache(self):
|
|
||||||
"""Test that the `update` queryset method properly invalidate cache."""
|
|
||||||
mem_skia = self.simple_board_member.memberships.get(club=self.club)
|
|
||||||
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
|
|
||||||
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
|
|
||||||
assert (
|
|
||||||
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
|
|
||||||
== "not_member"
|
|
||||||
)
|
|
||||||
|
|
||||||
mem_richard = self.richard.memberships.get(club=self.club)
|
|
||||||
cache.set(
|
|
||||||
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
|
|
||||||
)
|
|
||||||
self.richard.memberships.update(role=5)
|
|
||||||
new_mem = self.richard.memberships.get(club=self.club)
|
|
||||||
assert new_mem != "not_member"
|
|
||||||
assert new_mem.role == 5
|
|
||||||
|
|
||||||
def test_update_change_club_groups(self):
|
def test_update_change_club_groups(self):
|
||||||
"""Test that `update` set the user groups accordingly."""
|
"""Test that `update` set the user groups accordingly."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
membership = baker.make(Membership, end_date=None, user=user, role=5)
|
board_role, member_role = baker.make(
|
||||||
|
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
|
||||||
|
)
|
||||||
|
membership = baker.make(
|
||||||
|
Membership, end_date=None, user=user, role=board_role, club=board_role.club
|
||||||
|
)
|
||||||
members_group = membership.club.members_group
|
members_group = membership.club.members_group
|
||||||
board_group = membership.club.board_group
|
board_group = membership.club.board_group
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert user.groups.contains(board_group)
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
user.memberships.update(role=1) # from board to simple member
|
user.memberships.update(role=member_role) # from board to simple member
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert not user.groups.contains(board_group)
|
assert not user.groups.contains(board_group)
|
||||||
|
|
||||||
user.memberships.update(role=5) # from member to board
|
user.memberships.update(role=board_role) # from member to board
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert user.groups.contains(board_group)
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
@@ -112,28 +98,20 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
assert not user.groups.contains(members_group)
|
assert not user.groups.contains(members_group)
|
||||||
assert not user.groups.contains(board_group)
|
assert not user.groups.contains(board_group)
|
||||||
|
|
||||||
def test_delete_invalidate_cache(self):
|
|
||||||
"""Test that the `delete` queryset properly invalidate cache."""
|
|
||||||
mem_skia = self.simple_board_member.memberships.get(club=self.club)
|
|
||||||
mem_comptable = self.president.memberships.get(club=self.club)
|
|
||||||
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
|
|
||||||
cache.set(
|
|
||||||
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
|
|
||||||
)
|
|
||||||
|
|
||||||
# should delete the subscriptions of simple_board_member and president
|
|
||||||
self.club.members.ongoing().board().delete()
|
|
||||||
|
|
||||||
for membership in (mem_skia, mem_comptable):
|
|
||||||
cached_mem = cache.get(
|
|
||||||
f"membership_{membership.club_id}_{membership.user_id}"
|
|
||||||
)
|
|
||||||
assert cached_mem == "not_member"
|
|
||||||
|
|
||||||
def test_delete_remove_from_groups(self):
|
def test_delete_remove_from_groups(self):
|
||||||
"""Test that `delete` removes from club groups"""
|
"""Test that `delete` removes from club groups"""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
|
club = baker.make(Club)
|
||||||
|
roles = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
is_board=iter([False, True]),
|
||||||
|
club=club,
|
||||||
|
_quantity=2,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
memberships = baker.make(
|
||||||
|
Membership, club=club, role=iter(roles), user=user, _quantity=2
|
||||||
|
)
|
||||||
club_groups = {
|
club_groups = {
|
||||||
memberships[0].club.members_group,
|
memberships[0].club.members_group,
|
||||||
memberships[1].club.members_group,
|
memberships[1].club.members_group,
|
||||||
@@ -149,13 +127,20 @@ class TestMembershipEditableBy(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
Membership.objects.all().delete()
|
Membership.objects.all().delete()
|
||||||
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
||||||
|
roles = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
is_presidency=itertools.cycle([True, False, False, False]),
|
||||||
|
is_board=itertools.cycle([True, True, True, False]),
|
||||||
|
order=itertools.cycle(range(4)),
|
||||||
|
club=iter(
|
||||||
|
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
|
||||||
|
),
|
||||||
|
_quantity=8,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
cls.memberships = [
|
cls.memberships = [
|
||||||
*baker.make(
|
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
|
||||||
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
|
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
|
||||||
),
|
|
||||||
*baker.make(
|
|
||||||
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_admin_user(self):
|
def test_admin_user(self):
|
||||||
@@ -177,7 +162,7 @@ class TestMembershipEditableBy(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMembership(TestClub):
|
class TestMembership(TestClub):
|
||||||
def assert_membership_started_today(self, user: User, role: int):
|
def assert_membership_started_today(self, user: User, role: ClubRole):
|
||||||
"""Assert that the given membership is active and started today."""
|
"""Assert that the given membership is active and started today."""
|
||||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||||
assert membership is not None
|
assert membership is not None
|
||||||
@@ -226,21 +211,27 @@ class TestMembership(TestClub):
|
|||||||
"Marquer comme ancien",
|
"Marquer comme ancien",
|
||||||
]
|
]
|
||||||
rows = table.find("tbody").find_all("tr")
|
rows = table.find("tbody").find_all("tr")
|
||||||
memberships = self.club.members.ongoing().order_by("-role")
|
memberships = (
|
||||||
for row, membership in zip(
|
self.club.members.ongoing()
|
||||||
rows, memberships.select_related("user"), strict=False
|
.order_by("role__order")
|
||||||
):
|
.select_related("user", "role")
|
||||||
|
)
|
||||||
|
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||||
|
for row, membership in zip(rows, memberships, strict=False):
|
||||||
user = membership.user
|
user = membership.user
|
||||||
user_url = reverse("core:user_profile", args=[user.id])
|
user_url = reverse("core:user_profile", args=[user.id])
|
||||||
cols = row.find_all("td")
|
cols = row.find_all("td")
|
||||||
user_link = cols[0].find("a")
|
user_link = cols[0].find("a")
|
||||||
assert user_link.attrs["href"] == user_url
|
assert user_link.attrs["href"] == user_url
|
||||||
assert user_link.text == user.get_display_name()
|
assert user_link.text == user.get_display_name()
|
||||||
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
|
assert cols[1].text == membership.role.name
|
||||||
assert cols[2].text == membership.description
|
assert cols[2].text == membership.description
|
||||||
assert cols[3].text == str(membership.start_date)
|
assert cols[3].text == str(membership.start_date)
|
||||||
|
|
||||||
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
|
if (
|
||||||
|
membership.role.order > user_role.order
|
||||||
|
or membership.user_id == self.simple_board_member.id
|
||||||
|
):
|
||||||
# 3 is the role of simple_board_member
|
# 3 is the role of simple_board_member
|
||||||
form_input = cols[4].find("input")
|
form_input = cols[4].find("input")
|
||||||
expected_attrs = {
|
expected_attrs = {
|
||||||
@@ -256,14 +247,15 @@ class TestMembership(TestClub):
|
|||||||
"""Test that root users can add members to clubs"""
|
"""Test that root users can add members to clubs"""
|
||||||
self.client.force_login(self.root)
|
self.client.force_login(self.root)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.new_members_url, {"user": self.subscriber.id, "role": 3}
|
self.new_members_url,
|
||||||
|
{"user": self.subscriber.id, "role": self.board_role.id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||||
"club:club_members", kwargs={"club_id": self.club.id}
|
"club:club_members", kwargs={"club_id": self.club.id}
|
||||||
)
|
)
|
||||||
self.subscriber.refresh_from_db()
|
self.subscriber.refresh_from_db()
|
||||||
self.assert_membership_started_today(self.subscriber, role=3)
|
self.assert_membership_started_today(self.subscriber, role=self.board_role)
|
||||||
|
|
||||||
def test_add_unauthorized_members(self):
|
def test_add_unauthorized_members(self):
|
||||||
"""Test that users who are not currently subscribed
|
"""Test that users who are not currently subscribed
|
||||||
@@ -271,7 +263,7 @@ class TestMembership(TestClub):
|
|||||||
"""
|
"""
|
||||||
for user in self.public, self.old_subscriber:
|
for user in self.public, self.old_subscriber:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": user.id, "role": 1},
|
data={"user": user.id, "role": self.member_role},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -292,7 +284,7 @@ class TestMembership(TestClub):
|
|||||||
nb_memberships = self.simple_board_member.memberships.count()
|
nb_memberships = self.simple_board_member.memberships.count()
|
||||||
self.client.post(
|
self.client.post(
|
||||||
self.members_url,
|
self.members_url,
|
||||||
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
|
{"users": self.simple_board_member.id, "role": self.member_role},
|
||||||
)
|
)
|
||||||
self.simple_board_member.refresh_from_db()
|
self.simple_board_member.refresh_from_db()
|
||||||
assert nb_memberships == self.simple_board_member.memberships.count()
|
assert nb_memberships == self.simple_board_member.memberships.count()
|
||||||
@@ -311,7 +303,7 @@ class TestMembership(TestClub):
|
|||||||
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
||||||
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": members, "role": 1},
|
data={"user": members, "role": self.member_role},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -327,12 +319,13 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
def test_president_add_members(self):
|
def test_president_add_members(self):
|
||||||
"""Test that the president of the club can add members."""
|
"""Test that the president of the club can add members."""
|
||||||
president = self.club.members.get(role=10).user
|
president = self.club.members.get(role=self.president_role).user
|
||||||
nb_club_membership = self.club.members.count()
|
nb_club_membership = self.club.members.count()
|
||||||
nb_subscriber_memberships = self.subscriber.memberships.count()
|
nb_subscriber_memberships = self.subscriber.memberships.count()
|
||||||
self.client.force_login(president)
|
self.client.force_login(president)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.new_members_url, {"user": self.subscriber.id, "role": 9}
|
self.new_members_url,
|
||||||
|
{"user": self.subscriber.id, "role": self.president_role.id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||||
@@ -342,14 +335,17 @@ class TestMembership(TestClub):
|
|||||||
self.subscriber.refresh_from_db()
|
self.subscriber.refresh_from_db()
|
||||||
assert self.club.members.count() == nb_club_membership + 1
|
assert self.club.members.count() == nb_club_membership + 1
|
||||||
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
||||||
self.assert_membership_started_today(self.subscriber, role=9)
|
self.assert_membership_started_today(self.subscriber, role=self.president_role)
|
||||||
|
|
||||||
def test_add_member_greater_role(self):
|
def test_add_member_greater_role(self):
|
||||||
"""Test that a member of the club member cannot create
|
"""Test that a member of the club member cannot create
|
||||||
a membership with a greater role than its own.
|
a membership with a greater role than its own.
|
||||||
"""
|
"""
|
||||||
|
user_role = self.simple_board_member.memberships.first().role
|
||||||
|
other_role = baker.make(ClubRole, club=user_role.club, is_board=True)
|
||||||
|
other_role.above(user_role)
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": self.subscriber.id, "role": 10},
|
data={"user": self.subscriber.id, "role": other_role.id},
|
||||||
request_user=self.simple_board_member,
|
request_user=self.simple_board_member,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -357,7 +353,10 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {
|
assert form.errors == {
|
||||||
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
|
"role": [
|
||||||
|
"Sélectionnez un choix valide. "
|
||||||
|
"Ce choix ne fait pas partie de ceux disponibles."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
assert nb_memberships == self.club.members.count()
|
assert nb_memberships == self.club.members.count()
|
||||||
@@ -373,8 +372,9 @@ class TestMembership(TestClub):
|
|||||||
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
||||||
|
|
||||||
def test_add_member_already_there(self):
|
def test_add_member_already_there(self):
|
||||||
|
role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": self.simple_board_member, "role": 3},
|
data={"user": self.simple_board_member, "role": role.id},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -385,22 +385,27 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
def test_add_other_member_forbidden(self):
|
def test_add_other_member_forbidden(self):
|
||||||
non_member = subscriber_user.make()
|
non_member = subscriber_user.make()
|
||||||
simple_member = baker.make(Membership, club=self.club, role=1).user
|
simple_member = baker.make(
|
||||||
|
Membership, club=self.club, role=self.member_role
|
||||||
|
).user
|
||||||
for user in non_member, simple_member:
|
for user in non_member, simple_member:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": subscriber_user.make(), "role": 1},
|
data={"user": subscriber_user.make(), "role": self.member_role.id},
|
||||||
request_user=user,
|
request_user=user,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {
|
assert form.errors == {
|
||||||
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
|
"role": [
|
||||||
|
"Sélectionnez un choix valide. "
|
||||||
|
"Ce choix ne fait pas partie de ceux disponibles."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_simple_members_dont_see_form_anymore(self):
|
def test_simple_members_dont_see_form_anymore(self):
|
||||||
"""Test that simple club members don't see the form to add members"""
|
"""Test that simple club members don't see the form to add members"""
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, club=self.club, user=user, role=1)
|
baker.make(Membership, club=self.club, user=user, role=self.member_role)
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
res = self.client.get(self.members_url)
|
res = self.client.get(self.members_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -419,9 +424,10 @@ class TestMembership(TestClub):
|
|||||||
"""Test that board members of the club can end memberships
|
"""Test that board members of the club can end memberships
|
||||||
of users with lower roles.
|
of users with lower roles.
|
||||||
"""
|
"""
|
||||||
# reminder : simple_board_member has role 3
|
|
||||||
self.client.force_login(self.simple_board_member)
|
self.client.force_login(self.simple_board_member)
|
||||||
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
|
role = baker.make(ClubRole, club=self.club, is_board=True)
|
||||||
|
role.below(self.board_role)
|
||||||
|
membership = baker.make(Membership, club=self.club, role=role)
|
||||||
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||||
self.assertRedirects(response, self.members_url)
|
self.assertRedirects(response, self.members_url)
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -431,7 +437,9 @@ class TestMembership(TestClub):
|
|||||||
"""Test that board members of the club cannot end memberships
|
"""Test that board members of the club cannot end memberships
|
||||||
of users with higher roles.
|
of users with higher roles.
|
||||||
"""
|
"""
|
||||||
membership = self.president.memberships.filter(club=self.club).first()
|
membership = self.president.memberships.filter(
|
||||||
|
club=self.club, end_date=None
|
||||||
|
).first()
|
||||||
self.client.force_login(self.simple_board_member)
|
self.client.force_login(self.simple_board_member)
|
||||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -473,7 +481,9 @@ class TestMembership(TestClub):
|
|||||||
def test_remove_from_club_group(self):
|
def test_remove_from_club_group(self):
|
||||||
"""Test that when a membership ends, the user is removed from club groups."""
|
"""Test that when a membership ends, the user is removed from club groups."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
baker.make(
|
||||||
|
Membership, user=user, club=self.club, end_date=None, role=self.board_role
|
||||||
|
)
|
||||||
assert user.groups.contains(self.club.members_group)
|
assert user.groups.contains(self.club.members_group)
|
||||||
assert user.groups.contains(self.club.board_group)
|
assert user.groups.contains(self.club.board_group)
|
||||||
user.memberships.update(end_date=localdate())
|
user.memberships.update(end_date=localdate())
|
||||||
@@ -484,18 +494,20 @@ class TestMembership(TestClub):
|
|||||||
"""Test that when a membership begins, the user is added to the club group."""
|
"""Test that when a membership begins, the user is added to the club group."""
|
||||||
assert not self.subscriber.groups.contains(self.club.members_group)
|
assert not self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
|
baker.make(
|
||||||
|
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||||
|
)
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
|
|
||||||
def test_change_position_in_club(self):
|
def test_change_position_in_club(self):
|
||||||
"""Test that when moving from board to members, club group change"""
|
"""Test that when moving from board to members, club group change"""
|
||||||
membership = baker.make(
|
membership = baker.make(
|
||||||
Membership, club=self.club, user=self.subscriber, role=3
|
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||||
)
|
)
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
membership.role = 1
|
membership.role = self.member_role
|
||||||
membership.save()
|
membership.save()
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
@@ -508,7 +520,11 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
# make sli a board member
|
# make sli a board member
|
||||||
self.sli.memberships.all().delete()
|
self.sli.memberships.all().delete()
|
||||||
Membership(club=self.ae, user=self.sli, role=3).save()
|
Membership(
|
||||||
|
club=self.ae,
|
||||||
|
user=self.sli,
|
||||||
|
role=baker.make(ClubRole, club=self.ae, is_board=True),
|
||||||
|
).save()
|
||||||
assert self.club.is_owned_by(self.sli)
|
assert self.club.is_owned_by(self.sli)
|
||||||
|
|
||||||
def test_change_club_name(self):
|
def test_change_club_name(self):
|
||||||
@@ -532,6 +548,79 @@ class TestMembership(TestClub):
|
|||||||
assert new_board == initial_board
|
assert new_board == initial_board
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_membership_set_old(client: Client):
|
||||||
|
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
|
||||||
|
client.force_login(membership.user)
|
||||||
|
response = client.post(
|
||||||
|
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
||||||
|
)
|
||||||
|
assertRedirects(
|
||||||
|
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
|
||||||
|
)
|
||||||
|
membership.refresh_from_db()
|
||||||
|
assert membership.end_date == localdate()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_membership_delete(client: Client):
|
||||||
|
user = baker.make(User, is_superuser=True)
|
||||||
|
membership = baker.make(Membership)
|
||||||
|
client.force_login(user)
|
||||||
|
url = reverse("club:membership_delete", kwargs={"membership_id": membership.id})
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
response = client.post(url)
|
||||||
|
assertRedirects(
|
||||||
|
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
|
||||||
|
)
|
||||||
|
assert not Membership.objects.filter(id=membership.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestAddMemberForm(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = baker.make(Club)
|
||||||
|
cls.roles = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
club=cls.club,
|
||||||
|
is_board=iter([True, True, True, True, False, False]),
|
||||||
|
is_presidency=iter([True, True, False, False, False, False]),
|
||||||
|
order=seq(0),
|
||||||
|
_quantity=6,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
cls.roles[-1].is_active = False
|
||||||
|
cls.roles[-1].save()
|
||||||
|
|
||||||
|
def test_admin(self):
|
||||||
|
"""Test that admin users can give any active role."""
|
||||||
|
user = baker.make(
|
||||||
|
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
||||||
|
)
|
||||||
|
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||||
|
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||||
|
|
||||||
|
def test_president(self):
|
||||||
|
"""Test that someone with a presidency role can give any active role."""
|
||||||
|
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
|
||||||
|
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||||
|
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||||
|
|
||||||
|
def test_board_member(self):
|
||||||
|
"""Test that someone with a board role can give lower active role."""
|
||||||
|
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
|
||||||
|
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||||
|
assert list(form.fields["role"].queryset) == self.roles[3:-1]
|
||||||
|
|
||||||
|
def test_simple_member(self):
|
||||||
|
"""Test that someone with a non-board role cannot give roles."""
|
||||||
|
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
|
||||||
|
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||||
|
assert list(form.fields["role"].queryset) == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestJoinClub:
|
class TestJoinClub:
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -539,55 +628,64 @@ class TestJoinClub:
|
|||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("user_factory", "role", "errors"),
|
("user_factory", "board_role", "errors"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
subscriber_user.make,
|
subscriber_user.make,
|
||||||
2,
|
True,
|
||||||
{
|
{
|
||||||
"role": [
|
"role": [
|
||||||
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
|
"Sélectionnez un choix valide. "
|
||||||
|
"Ce choix ne fait pas partie de ceux disponibles."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
lambda: baker.make(User),
|
lambda: baker.make(User),
|
||||||
1,
|
False,
|
||||||
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_join_club_errors(
|
def test_join_club_errors(
|
||||||
self, user_factory: Callable[[], User], role: int, errors: dict
|
self, user_factory: Callable[[], User], board_role, errors: dict
|
||||||
):
|
):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = user_factory()
|
user = user_factory()
|
||||||
form = JoinClubForm(club=club, request_user=user, data={"role": role})
|
role = baker.make(ClubRole, club=club, is_board=board_role)
|
||||||
|
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == errors
|
assert form.errors == errors
|
||||||
|
|
||||||
def test_user_already_in_club(self):
|
def test_user_already_in_club(self):
|
||||||
club = baker.make(Club)
|
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, user=user, club=club)
|
role = baker.make(ClubRole, is_board=False)
|
||||||
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
baker.make(Membership, user=user, club=role.club)
|
||||||
|
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
||||||
|
|
||||||
def test_ok(self):
|
def test_ok(self):
|
||||||
club = baker.make(Club)
|
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
role = baker.make(ClubRole, is_board=False)
|
||||||
|
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
form.save()
|
form.save()
|
||||||
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
|
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
|
||||||
|
|
||||||
|
|
||||||
class TestOldMembersView(TestCase):
|
class TestOldMembersView(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
|
roles = baker.make(
|
||||||
|
ClubRole,
|
||||||
|
club=club,
|
||||||
|
is_board=itertools.cycle([True, True, False]),
|
||||||
|
order=seq(0),
|
||||||
|
_quantity=10,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
cls.memberships = baker.make(
|
cls.memberships = baker.make(
|
||||||
Membership,
|
Membership,
|
||||||
role=iter(roles),
|
role=iter(roles),
|
||||||
@@ -612,3 +710,11 @@ class TestOldMembersView(TestCase):
|
|||||||
self.client.force_login(baker.make(User))
|
self.client.force_login(baker.make(User))
|
||||||
res = self.client.get(self.url)
|
res = self.client.get(self.url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
def test_context_data(self):
|
||||||
|
# mark a membership as not ended, to make sure it is excluded from the result
|
||||||
|
self.memberships[0].end_date = None
|
||||||
|
self.memberships[0].save()
|
||||||
|
self.client.force_login(subscriber_user.make())
|
||||||
|
res = self.client.get(self.url)
|
||||||
|
assert list(res.context_data.get("old_members")) == self.memberships[1:]
|
||||||
|
|||||||
+44
-7
@@ -1,12 +1,13 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from aemark import markdown
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertHTMLEqual
|
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.markdown import markdown
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import PageRev, User
|
from core.models import PageRev, User
|
||||||
|
|
||||||
|
|
||||||
@@ -16,12 +17,11 @@ def test_page_display_on_club_main_page(client: Client):
|
|||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
content = "# foo\nLorem ipsum dolor sit amet"
|
content = "# foo\nLorem ipsum dolor sit amet"
|
||||||
baker.make(PageRev, page=club.page, revision=1, content=content)
|
baker.make(PageRev, page=club.page, revision=1, content=content)
|
||||||
client.force_login(baker.make(User))
|
|
||||||
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||||
|
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
soup = BeautifulSoup(res.text, "lxml")
|
soup = BeautifulSoup(res.text, "lxml")
|
||||||
detail_html = soup.find(id="club_detail").find(class_="markdown")
|
detail_html = soup.find(id="club-page").find(class_="markdown")
|
||||||
assertHTMLEqual(detail_html.decode_contents(), markdown(content))
|
assertHTMLEqual(detail_html.decode_contents(), markdown(content))
|
||||||
|
|
||||||
|
|
||||||
@@ -30,10 +30,47 @@ def test_club_main_page_without_content(client: Client):
|
|||||||
"""Test the club view works, even if the club page is empty"""
|
"""Test the club view works, even if the club page is empty"""
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
club.page.revisions.all().delete()
|
club.page.revisions.all().delete()
|
||||||
client.force_login(baker.make(User))
|
|
||||||
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||||
|
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
soup = BeautifulSoup(res.text, "lxml")
|
soup = BeautifulSoup(res.text, "lxml")
|
||||||
detail_html = soup.find(id="club_detail")
|
detail_html = soup.find(id="club-page")
|
||||||
assert detail_html.find_all("markdown") == []
|
assert detail_html.find_all("markdown") == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_page_revision(client: Client):
|
||||||
|
club = baker.make(Club)
|
||||||
|
revisions = baker.make(
|
||||||
|
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
|
||||||
|
)
|
||||||
|
client.force_login(baker.make(User))
|
||||||
|
url = reverse(
|
||||||
|
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
|
||||||
|
)
|
||||||
|
res = client.get(url)
|
||||||
|
assert res.status_code == 200
|
||||||
|
soup = BeautifulSoup(res.text, "lxml")
|
||||||
|
detail_html = soup.find(class_="markdown")
|
||||||
|
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_edit_page(client: Client):
|
||||||
|
club = baker.make(Club)
|
||||||
|
user = subscriber_user.make()
|
||||||
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
user=user,
|
||||||
|
club=club,
|
||||||
|
role=baker.make(ClubRole, club=club, is_board=True),
|
||||||
|
)
|
||||||
|
client.force_login(user)
|
||||||
|
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
||||||
|
content = "# foo\nLorem ipsum dolor sit amet"
|
||||||
|
|
||||||
|
res = client.get(url)
|
||||||
|
assert res.status_code == 200
|
||||||
|
res = client.post(url, data={"content": content})
|
||||||
|
assertRedirects(res, reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||||
|
assert club.page.revisions.last().content == content
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import csv
|
||||||
|
import itertools
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -7,16 +10,20 @@ from club.forms import SellingsForm
|
|||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.baker_recipes import product_recipe, sale_recipe
|
from counter.baker_recipes import product_recipe, sale_recipe
|
||||||
from counter.models import Counter, Customer
|
from counter.models import Counter, Customer, Product, Selling
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_sales_page_doesnt_crash(client: Client):
|
def test_sales_page_doesnt_crash(client: Client):
|
||||||
|
"""Basic crashtest on club sales view."""
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
|
product = baker.make(Product, club=club)
|
||||||
admin = baker.make(User, is_superuser=True)
|
admin = baker.make(User, is_superuser=True)
|
||||||
client.force_login(admin)
|
client.force_login(admin)
|
||||||
response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
|
url = reverse("club:club_sellings", kwargs={"club_id": club.id})
|
||||||
assert response.status_code == 200
|
assert client.get(url).status_code == 200
|
||||||
|
assert client.post(url).status_code == 200
|
||||||
|
assert client.post(url, data={"products": [product.id]}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -36,3 +43,62 @@ def test_sales_form_counter_filter():
|
|||||||
form = SellingsForm(club)
|
form = SellingsForm(club)
|
||||||
form_counters = list(form.fields["counters"].queryset)
|
form_counters = list(form.fields["counters"].queryset)
|
||||||
assert form_counters == [counters[1], counters[2], counters[0]]
|
assert form_counters == [counters[1], counters[2], counters[0]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_club_sales_csv(client: Client):
|
||||||
|
client.force_login(baker.make(User, is_superuser=True))
|
||||||
|
club = baker.make(Club)
|
||||||
|
counter = baker.make(Counter, club=club)
|
||||||
|
product = product_recipe.make(club=club, counters=[counter], purchase_price=0.5)
|
||||||
|
customers = baker.make(Customer, amount=100, _quantity=2, _bulk_create=True)
|
||||||
|
sales: list[Selling] = sale_recipe.make(
|
||||||
|
club=club,
|
||||||
|
counter=counter,
|
||||||
|
quantity=2,
|
||||||
|
unit_price=1.5,
|
||||||
|
product=iter([product, product, None]),
|
||||||
|
customer=itertools.cycle(customers),
|
||||||
|
_quantity=3,
|
||||||
|
)
|
||||||
|
url = reverse("club:sellings_csv", kwargs={"club_id": club.id})
|
||||||
|
response = client.post(url, data={"counters": [counter.id]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
reader = csv.reader(s.decode() for s in response.streaming_content)
|
||||||
|
data = list(reader)
|
||||||
|
sale_rows = [
|
||||||
|
[
|
||||||
|
str(s.date),
|
||||||
|
str(counter),
|
||||||
|
str(s.seller),
|
||||||
|
s.customer.user.get_display_name(),
|
||||||
|
s.label,
|
||||||
|
"2",
|
||||||
|
"1.50",
|
||||||
|
"3.00",
|
||||||
|
"Compte utilisateur",
|
||||||
|
]
|
||||||
|
for s in sales[::-1]
|
||||||
|
]
|
||||||
|
sale_rows[2].extend(["0.50", "1.00"])
|
||||||
|
sale_rows[1].extend(["0.50", "1.00"])
|
||||||
|
sale_rows[0].extend(["", ""])
|
||||||
|
assert data == [
|
||||||
|
["Quantité", "6"],
|
||||||
|
["Total", "9"],
|
||||||
|
["Bénéfice", "1"],
|
||||||
|
[
|
||||||
|
"Date",
|
||||||
|
"Comptoir",
|
||||||
|
"Barman",
|
||||||
|
"Client",
|
||||||
|
"Étiquette",
|
||||||
|
"Quantité",
|
||||||
|
"Prix unitaire",
|
||||||
|
"Total",
|
||||||
|
"Méthode de paiement",
|
||||||
|
"Prix d'achat",
|
||||||
|
"Bénéfice",
|
||||||
|
],
|
||||||
|
*sale_rows,
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import localdate
|
||||||
|
from model_bakery import baker
|
||||||
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
|
from club.models import Club, ClubRole, Membership
|
||||||
|
from club.schemas import UserMembershipSchema
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import Page
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchClub(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = subscriber_user.make()
|
||||||
|
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
||||||
|
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
||||||
|
recipe = Recipe(
|
||||||
|
Membership,
|
||||||
|
user=cls.user,
|
||||||
|
start_date=localdate() - timedelta(days=2),
|
||||||
|
role=baker.make(ClubRole),
|
||||||
|
)
|
||||||
|
cls.members = Membership.objects.bulk_create(
|
||||||
|
[
|
||||||
|
recipe.prepare(club=clubs[0]),
|
||||||
|
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
|
||||||
|
recipe.prepare(club=clubs[1]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fetch_memberships(self):
|
||||||
|
self.client.force_login(subscriber_user.make())
|
||||||
|
res = self.client.get(
|
||||||
|
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
|
||||||
|
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_fetch_club_nb_queries(self):
|
||||||
|
self.client.force_login(subscriber_user.make())
|
||||||
|
with self.assertNumQueries(6):
|
||||||
|
# - 5 queries for authentication
|
||||||
|
# - 1 query for the actual data
|
||||||
|
res = self.client.get(
|
||||||
|
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
@@ -35,6 +35,10 @@ from club.views import (
|
|||||||
ClubPageEditView,
|
ClubPageEditView,
|
||||||
ClubPageHistView,
|
ClubPageHistView,
|
||||||
ClubRevView,
|
ClubRevView,
|
||||||
|
ClubRoleBoardCreateView,
|
||||||
|
ClubRoleMemberCreateView,
|
||||||
|
ClubRolePresidencyCreateView,
|
||||||
|
ClubRoleUpdateView,
|
||||||
ClubSellingCSVView,
|
ClubSellingCSVView,
|
||||||
ClubSellingView,
|
ClubSellingView,
|
||||||
ClubToolsView,
|
ClubToolsView,
|
||||||
@@ -71,6 +75,22 @@ urlpatterns = [
|
|||||||
ClubOldMembersView.as_view(),
|
ClubOldMembersView.as_view(),
|
||||||
name="club_old_members",
|
name="club_old_members",
|
||||||
),
|
),
|
||||||
|
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
|
||||||
|
path(
|
||||||
|
"<int:club_id>/role/new/president/",
|
||||||
|
ClubRolePresidencyCreateView.as_view(),
|
||||||
|
name="new_role_president",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<int:club_id>/role/new/board/",
|
||||||
|
ClubRoleBoardCreateView.as_view(),
|
||||||
|
name="new_role_board",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<int:club_id>/role/new/member/",
|
||||||
|
ClubRoleMemberCreateView.as_view(),
|
||||||
|
name="new_role_member",
|
||||||
|
),
|
||||||
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
||||||
path(
|
path(
|
||||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||||
|
|||||||
+228
-85
@@ -22,37 +22,55 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import itertools
|
import itertools
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.contrib.auth.mixins import (
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
LoginRequiredMixin,
|
||||||
|
PermissionRequiredMixin,
|
||||||
|
UserPassesTestMixin,
|
||||||
|
)
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
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 F, Q, Sum
|
from django.db.models import F, Prefetch, Q, Sum
|
||||||
from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
|
from django.db.models.functions import Length
|
||||||
|
from django.http import Http404, StreamingHttpResponse
|
||||||
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.safestring import SafeString
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
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, View
|
||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
from django.views.generic.edit import CreateView, DeleteView, FormMixin, UpdateView
|
||||||
|
|
||||||
from club.forms import (
|
from club.forms import (
|
||||||
ClubAddMemberForm,
|
ClubAddMemberForm,
|
||||||
ClubAdminEditForm,
|
ClubAdminEditForm,
|
||||||
ClubEditForm,
|
ClubEditForm,
|
||||||
ClubOldMemberForm,
|
ClubOldMemberForm,
|
||||||
|
ClubRoleCreateForm,
|
||||||
|
ClubRoleFormSet,
|
||||||
|
ClubSearchForm,
|
||||||
JoinClubForm,
|
JoinClubForm,
|
||||||
MailingForm,
|
MailingForm,
|
||||||
SellingsForm,
|
SellingsForm,
|
||||||
)
|
)
|
||||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
from club.models import (
|
||||||
|
Club,
|
||||||
|
ClubLink,
|
||||||
|
ClubRole,
|
||||||
|
LinkType,
|
||||||
|
Mailing,
|
||||||
|
MailingSubscription,
|
||||||
|
Membership,
|
||||||
|
)
|
||||||
from com.models import Poster
|
from com.models import Poster
|
||||||
from com.views import (
|
from com.views import (
|
||||||
PosterCreateBaseView,
|
PosterCreateBaseView,
|
||||||
@@ -61,11 +79,19 @@ from com.views import (
|
|||||||
PosterListBaseView,
|
PosterListBaseView,
|
||||||
)
|
)
|
||||||
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
|
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
|
||||||
from core.models import PageRev
|
from core.models import Page, PageRev
|
||||||
from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
|
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
|
||||||
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
|
from core.views.mixins import (
|
||||||
|
AllowFragment,
|
||||||
|
FragmentMixin,
|
||||||
|
FragmentRenderer,
|
||||||
|
TabedViewMixin,
|
||||||
|
)
|
||||||
from counter.models import Selling
|
from counter.models import Selling
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.utils.safestring import SafeString
|
||||||
|
|
||||||
|
|
||||||
class ClubTabsMixin(TabedViewMixin):
|
class ClubTabsMixin(TabedViewMixin):
|
||||||
def get_tabs_title(self):
|
def get_tabs_title(self):
|
||||||
@@ -75,6 +101,8 @@ class ClubTabsMixin(TabedViewMixin):
|
|||||||
self.object = self.object.page.club
|
self.object = self.object.page.club
|
||||||
elif isinstance(self.object, Poster):
|
elif isinstance(self.object, Poster):
|
||||||
self.object = self.object.club
|
self.object = self.object.club
|
||||||
|
elif hasattr(self, "club"):
|
||||||
|
self.object = self.club
|
||||||
return self.object.get_display_name()
|
return self.object.get_display_name()
|
||||||
|
|
||||||
def get_list_of_tabs(self):
|
def get_list_of_tabs(self):
|
||||||
@@ -172,15 +200,43 @@ class ClubTabsMixin(TabedViewMixin):
|
|||||||
return tab_list
|
return tab_list
|
||||||
|
|
||||||
|
|
||||||
class ClubListView(ListView):
|
class ClubListView(AllowFragment, FormMixin, ListView):
|
||||||
"""List the Clubs."""
|
"""List the clubs of the AE, with a form to perform basic search.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
This view is fully public, because we want to advertise as much as possible
|
||||||
|
the cultural life of the AE.
|
||||||
|
In accordance with that matter, searching and listing the clubs is done
|
||||||
|
entirely server-side (no AlpineJS involved) ;
|
||||||
|
this is done this way in order to be sure the page is the most accessible
|
||||||
|
and SEO-friendly possible, even if it makes the UX slightly less smooth.
|
||||||
|
"""
|
||||||
|
|
||||||
model = Club
|
|
||||||
template_name = "club/club_list.jinja"
|
template_name = "club/club_list.jinja"
|
||||||
queryset = (
|
form_class = ClubSearchForm
|
||||||
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
|
queryset = Club.objects.prefetch_related(
|
||||||
)
|
Prefetch("links", queryset=ClubLink.objects.select_related("link_type"))
|
||||||
context_object_name = "club_list"
|
).order_by("name")
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
res = super().get_form_kwargs()
|
||||||
|
# if request.GET is empty, the form will interpret club_status as None,
|
||||||
|
# even though we want it to be initially True,
|
||||||
|
# so we force a defaut True value.
|
||||||
|
res["data"] = {"club_status": True} | self.request.GET.dict()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
form: ClubSearchForm = self.get_form()
|
||||||
|
qs = self.queryset
|
||||||
|
if not form.is_valid():
|
||||||
|
return qs.none()
|
||||||
|
if name := form.cleaned_data.get("name"):
|
||||||
|
qs = qs.filter(name__icontains=name)
|
||||||
|
if (is_active := form.cleaned_data.get("club_status")) is not None:
|
||||||
|
qs = qs.filter(is_active=is_active)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class ClubView(ClubTabsMixin, DetailView):
|
class ClubView(ClubTabsMixin, DetailView):
|
||||||
@@ -199,10 +255,11 @@ class ClubView(ClubTabsMixin, DetailView):
|
|||||||
.values_list("content", flat=True)
|
.values_list("content", flat=True)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
kwargs["links"] = list(self.object.links.select_related("link_type").all())
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
class ClubRevView(ClubView):
|
class ClubRevView(LoginRequiredMixin, ClubView):
|
||||||
"""Display a specific page revision."""
|
"""Display a specific page revision."""
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
@@ -216,26 +273,26 @@ class ClubRevView(ClubView):
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
|
class ClubPageEditView(ClubTabsMixin, BasePageEditView):
|
||||||
template_name = "club/pagerev_edit.jinja"
|
template_name = "club/pagerev_edit.jinja"
|
||||||
current_tab = "page_edit"
|
current_tab = "page_edit"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
@cached_property
|
||||||
self.club = get_object_or_404(Club, pk=kwargs["club_id"])
|
def club(self):
|
||||||
if not self.club.page:
|
return get_object_or_404(Club, pk=self.kwargs["club_id"])
|
||||||
raise Http404
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_object(self):
|
@cached_property
|
||||||
self.page = self.club.page
|
def page(self) -> Page:
|
||||||
return self._get_revision()
|
page = self.club.page
|
||||||
|
page.set_lock(self.request.user)
|
||||||
|
return page
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
|
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
|
||||||
|
|
||||||
|
|
||||||
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
||||||
"""Modification hostory of the page."""
|
"""Modification history of the page."""
|
||||||
|
|
||||||
model = Club
|
model = Club
|
||||||
pk_url_kwarg = "club_id"
|
pk_url_kwarg = "club_id"
|
||||||
@@ -310,7 +367,7 @@ class ClubMembersView(
|
|||||||
membership = self.object.get_membership_for(self.request.user)
|
membership = self.object.get_membership_for(self.request.user)
|
||||||
if (
|
if (
|
||||||
membership
|
membership
|
||||||
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
|
and not membership.role.is_board
|
||||||
and not self.request.user.has_perm("club.add_membership")
|
and not self.request.user.has_perm("club.add_membership")
|
||||||
):
|
):
|
||||||
# Simple club members won't see the form anymore.
|
# Simple club members won't see the form anymore.
|
||||||
@@ -335,8 +392,8 @@ class ClubMembersView(
|
|||||||
kwargs["members"] = list(
|
kwargs["members"] = list(
|
||||||
self.object.members.ongoing()
|
self.object.members.ongoing()
|
||||||
.annotate(is_editable=Q(id__in=editable))
|
.annotate(is_editable=Q(id__in=editable))
|
||||||
.order_by("-role")
|
.order_by("role__order")
|
||||||
.select_related("user")
|
.select_related("user", "role")
|
||||||
)
|
)
|
||||||
kwargs["can_end_membership"] = len(editable) > 0
|
kwargs["can_end_membership"] = len(editable) > 0
|
||||||
return kwargs
|
return kwargs
|
||||||
@@ -364,12 +421,125 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
|||||||
return super().get_context_data(**kwargs) | {
|
return super().get_context_data(**kwargs) | {
|
||||||
"old_members": (
|
"old_members": (
|
||||||
self.object.members.exclude(end_date=None)
|
self.object.members.exclude(end_date=None)
|
||||||
.order_by("-role", "description", "-end_date")
|
.order_by("role__order", "description", "-end_date")
|
||||||
.select_related("user")
|
.select_related("user", "role")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleUpdateView(
|
||||||
|
ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
|
||||||
|
):
|
||||||
|
form_class = ClubRoleFormSet
|
||||||
|
model = Club
|
||||||
|
template_name = "club/club_roles.jinja"
|
||||||
|
pk_url_kwarg = "club_id"
|
||||||
|
current_tab = "members"
|
||||||
|
success_message = _("Club roles updated")
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def club(self) -> Club:
|
||||||
|
return self.get_object()
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
return self.club.can_roles_be_edited_by(self.request.user)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}}
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("club:club_members", kwargs={"club_id": self.club.id})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"user_role": ClubRole.objects.filter(
|
||||||
|
club=self.club,
|
||||||
|
members__user=self.request.user,
|
||||||
|
members__end_date=None,
|
||||||
|
)
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView):
|
||||||
|
"""View to create a new Club Role, using [][club.forms.ClubRoleCreateForm].
|
||||||
|
|
||||||
|
This view isn't meant to be called directly, but rather subclassed for each
|
||||||
|
type of role that can exist :
|
||||||
|
|
||||||
|
- `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]`
|
||||||
|
to create a presidency role
|
||||||
|
- `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]`
|
||||||
|
to create a board role
|
||||||
|
- `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]`
|
||||||
|
to create a member role
|
||||||
|
|
||||||
|
Each subclass have to override the following variables :
|
||||||
|
|
||||||
|
- `is_presidency` and `is_board`, indicating what type of role
|
||||||
|
the view creates.
|
||||||
|
- `role_description`, which is the title of the page, indication
|
||||||
|
the user what kind of role is being created.
|
||||||
|
|
||||||
|
This way, we are making sure the correct type of role will
|
||||||
|
be created, without bothering the user with the implementation details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
form_class = ClubRoleCreateForm
|
||||||
|
model = ClubRole
|
||||||
|
template_name = "core/create.jinja"
|
||||||
|
success_message = _("Role %(name)s created")
|
||||||
|
role_description = ""
|
||||||
|
is_presidency: bool
|
||||||
|
is_board: bool
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def club(self):
|
||||||
|
return get_object_or_404(Club, id=self.kwargs["club_id"])
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
return self.request.user.is_authenticated and (
|
||||||
|
self.request.user.has_perm("club.add_clubrole")
|
||||||
|
or self.club.members.filter(
|
||||||
|
user=self.request.user, role__is_presidency=True
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return super().get_form_kwargs() | {
|
||||||
|
"club": self.club,
|
||||||
|
"is_presidency": self.is_presidency,
|
||||||
|
"is_board": self.is_board,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"object_name": self.role_description
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("club:club_roles", kwargs={"club_id": self.club.id})
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRolePresidencyCreateView(ClubRoleBaseCreateView):
|
||||||
|
is_presidency = True
|
||||||
|
is_board = True
|
||||||
|
role_description = _("club role \u2013 presidency")
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleBoardCreateView(ClubRoleBaseCreateView):
|
||||||
|
is_presidency = False
|
||||||
|
is_board = True
|
||||||
|
role_description = _("club role \u2013 board")
|
||||||
|
|
||||||
|
|
||||||
|
class ClubRoleMemberCreateView(ClubRoleBaseCreateView):
|
||||||
|
is_presidency = False
|
||||||
|
is_board = False
|
||||||
|
role_description = _("club role \u2013 member")
|
||||||
|
|
||||||
|
|
||||||
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||||
"""Sales of a club."""
|
"""Sales of a club."""
|
||||||
|
|
||||||
@@ -399,33 +569,14 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
|||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
kwargs["result"] = Selling.objects.none()
|
kwargs["result"] = Selling.objects.none()
|
||||||
kwargs["paginated_result"] = kwargs["result"]
|
|
||||||
kwargs["total"] = 0
|
kwargs["total"] = 0
|
||||||
kwargs["total_quantity"] = 0
|
kwargs["total_quantity"] = 0
|
||||||
kwargs["benefit"] = 0
|
kwargs["benefit"] = 0
|
||||||
|
|
||||||
form = self.get_form()
|
form: SellingsForm = self.get_form()
|
||||||
if form.is_valid():
|
if form.is_valid() and any(v for v in form.cleaned_data.values()):
|
||||||
qs = Selling.objects.filter(club=self.object)
|
filters = form.to_filter_schema()
|
||||||
if not len([v for v in form.cleaned_data.values() if v is not None]):
|
qs = filters.filter(Selling.objects.filter(club=self.object))
|
||||||
qs = Selling.objects.none()
|
|
||||||
if form.cleaned_data["begin_date"]:
|
|
||||||
qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
|
|
||||||
if form.cleaned_data["end_date"]:
|
|
||||||
qs = qs.filter(date__lte=form.cleaned_data["end_date"])
|
|
||||||
|
|
||||||
if form.cleaned_data["counters"]:
|
|
||||||
qs = qs.filter(counter__in=form.cleaned_data["counters"])
|
|
||||||
|
|
||||||
selected_products = []
|
|
||||||
if form.cleaned_data["products"]:
|
|
||||||
selected_products.extend(form.cleaned_data["products"])
|
|
||||||
if form.cleaned_data["archived_products"]:
|
|
||||||
selected_products.extend(form.cleaned_data["archived_products"])
|
|
||||||
|
|
||||||
if len(selected_products) > 0:
|
|
||||||
qs = qs.filter(product__in=selected_products)
|
|
||||||
|
|
||||||
kwargs["total"] = qs.annotate(
|
kwargs["total"] = qs.annotate(
|
||||||
price=F("quantity") * F("unit_price")
|
price=F("quantity") * F("unit_price")
|
||||||
).aggregate(total=Sum("price", default=0))["total"]
|
).aggregate(total=Sum("price", default=0))["total"]
|
||||||
@@ -472,15 +623,15 @@ class ClubSellingCSVView(ClubSellingView):
|
|||||||
*row,
|
*row,
|
||||||
selling.label,
|
selling.label,
|
||||||
selling.quantity,
|
selling.quantity,
|
||||||
|
selling.unit_price,
|
||||||
selling.quantity * selling.unit_price,
|
selling.quantity * selling.unit_price,
|
||||||
selling.get_payment_method_display(),
|
selling.get_payment_method_display(),
|
||||||
]
|
]
|
||||||
if selling.product:
|
if selling.product:
|
||||||
row.append(selling.product.selling_price)
|
|
||||||
row.append(selling.product.purchase_price)
|
row.append(selling.product.purchase_price)
|
||||||
row.append(selling.product.selling_price - selling.product.purchase_price)
|
row.append(selling.unit_price - selling.product.purchase_price)
|
||||||
else:
|
else:
|
||||||
row = [*row, "", "", ""]
|
row = [*row, "", ""]
|
||||||
return row
|
return row
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
@@ -501,9 +652,9 @@ class ClubSellingCSVView(ClubSellingView):
|
|||||||
gettext("Customer"),
|
gettext("Customer"),
|
||||||
gettext("Label"),
|
gettext("Label"),
|
||||||
gettext("Quantity"),
|
gettext("Quantity"),
|
||||||
|
gettext("Unit price"),
|
||||||
gettext("Total"),
|
gettext("Total"),
|
||||||
gettext("Payment method"),
|
gettext("Payment method"),
|
||||||
gettext("Selling price"),
|
|
||||||
gettext("Purchase price"),
|
gettext("Purchase price"),
|
||||||
gettext("Benefit"),
|
gettext("Benefit"),
|
||||||
],
|
],
|
||||||
@@ -545,6 +696,11 @@ class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView):
|
|||||||
return ClubAdminEditForm
|
return ClubAdminEditForm
|
||||||
return ClubEditForm
|
return ClubEditForm
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"link_types": list(LinkType.objects.order_by(Length("url_base").desc()))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ClubCreateView(PermissionRequiredMixin, CreateView):
|
class ClubCreateView(PermissionRequiredMixin, CreateView):
|
||||||
"""Create a club (for the Sith admin)."""
|
"""Create a club (for the Sith admin)."""
|
||||||
@@ -555,34 +711,23 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
|
|||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
permission_required = "club.add_club"
|
permission_required = "club.add_club"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
res = super().form_valid(form)
|
||||||
|
self.object.create_default_roles()
|
||||||
|
return res
|
||||||
|
|
||||||
class MembershipSetOldView(CanEditMixin, DetailView):
|
|
||||||
"""Set a membership as beeing old."""
|
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
|
||||||
|
"""Set a membership as being old."""
|
||||||
|
|
||||||
model = Membership
|
model = Membership
|
||||||
pk_url_kwarg = "membership_id"
|
pk_url_kwarg = "membership_id"
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def post(self, *_args, **_kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
self.object.end_date = timezone.now()
|
self.object.end_date = timezone.now()
|
||||||
self.object.save()
|
self.object.save()
|
||||||
return HttpResponseRedirect(
|
return redirect("core:user_clubs", user_id=self.object.user_id)
|
||||||
reverse(
|
|
||||||
"club:club_members",
|
|
||||||
args=self.args,
|
|
||||||
kwargs={"club_id": self.object.club.id},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
self.object = self.get_object()
|
|
||||||
return HttpResponseRedirect(
|
|
||||||
reverse(
|
|
||||||
"club:club_members",
|
|
||||||
args=self.args,
|
|
||||||
kwargs={"club_id": self.object.club.id},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
|
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
|
||||||
@@ -594,7 +739,7 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
|
|||||||
permission_required = "club.delete_membership"
|
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})
|
||||||
|
|
||||||
|
|
||||||
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||||
@@ -751,9 +896,7 @@ class MailingAutoGenerationView(View):
|
|||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
club = self.mailing.club
|
club = self.mailing.club
|
||||||
self.mailing.subscriptions.all().delete()
|
self.mailing.subscriptions.all().delete()
|
||||||
members = club.members.filter(
|
members = club.members.ongoing().filter(role__is_board=True)
|
||||||
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
|
|
||||||
).exclude(end_date__lte=timezone.now())
|
|
||||||
for member in members.all():
|
for member in members.all():
|
||||||
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
||||||
return redirect("club:mailing", club_id=club.id)
|
return redirect("club:mailing", club_id=club.id)
|
||||||
|
|||||||
+27
-15
@@ -4,15 +4,16 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from django.contrib.syndication.views import add_domain
|
from django.contrib.syndication.views import add_domain
|
||||||
from django.db.models import F, QuerySet
|
from django.db.models import Count, OuterRef, QuerySet, Subquery
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
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
|
||||||
from ical.calendar_stream import IcsCalendarStream
|
from ical.calendar_stream import IcsCalendarStream
|
||||||
from ical.event import Event
|
from ical.event import Event
|
||||||
|
from ical.types import Frequency, Recur
|
||||||
|
|
||||||
from com.models import NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@@ -42,9 +43,9 @@ class IcsCalendar:
|
|||||||
with open(cls._INTERNAL_CALENDAR, "wb") as f:
|
with open(cls._INTERNAL_CALENDAR, "wb") as f:
|
||||||
_ = f.write(
|
_ = f.write(
|
||||||
cls.ics_from_queryset(
|
cls.ics_from_queryset(
|
||||||
NewsDate.objects.filter(
|
News.objects.filter(
|
||||||
news__is_published=True,
|
is_published=True,
|
||||||
end_date__gte=timezone.now() - (relativedelta(months=6)),
|
dates__end_date__gte=timezone.now() - relativedelta(months=6),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -53,24 +54,35 @@ class IcsCalendar:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_unpublished(cls, user: User) -> bytes:
|
def get_unpublished(cls, user: User) -> bytes:
|
||||||
return cls.ics_from_queryset(
|
return cls.ics_from_queryset(
|
||||||
NewsDate.objects.viewable_by(user).filter(
|
News.objects.viewable_by(user).filter(
|
||||||
news__is_published=False,
|
is_published=False,
|
||||||
end_date__gte=timezone.now() - (relativedelta(months=6)),
|
dates__end_date__gte=timezone.now() - relativedelta(months=6),
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
|
def ics_from_queryset(cls, queryset: QuerySet[News]) -> bytes:
|
||||||
calendar = Calendar()
|
calendar = Calendar()
|
||||||
for news_date in queryset.annotate(news_title=F("news__title")):
|
date_subquery = NewsDate.objects.filter(news=OuterRef("pk")).order_by(
|
||||||
|
"start_date"
|
||||||
|
)
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
start=Subquery(date_subquery.values("start_date")[:1]),
|
||||||
|
end=Subquery(date_subquery.values("end_date")[:1]),
|
||||||
|
nb_dates=Count("dates"),
|
||||||
|
)
|
||||||
|
for news in queryset:
|
||||||
event = Event(
|
event = Event(
|
||||||
summary=news_date.news_title,
|
summary=news.title,
|
||||||
start=news_date.start_date,
|
description=news.summary,
|
||||||
end=news_date.end_date,
|
dtstart=news.start,
|
||||||
|
dtend=news.end,
|
||||||
url=as_absolute_url(
|
url=as_absolute_url(
|
||||||
reverse("com:news_detail", kwargs={"news_id": news_date.news_id})
|
reverse("com:news_detail", kwargs={"news_id": news.id})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if news.nb_dates > 1:
|
||||||
|
event.rrule = Recur(freq=Frequency.WEEKLY, count=news.nb_dates)
|
||||||
calendar.events.append(event)
|
calendar.events.append(event)
|
||||||
|
|
||||||
return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")
|
return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")
|
||||||
|
|||||||
+8
-8
@@ -1,22 +1,22 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from ninja import FilterSchema, ModelSchema
|
from aemark import markdown
|
||||||
|
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||||
from ninja_extra import service_resolver
|
from ninja_extra import service_resolver
|
||||||
from ninja_extra.context import RouteContext
|
from ninja_extra.context import RouteContext
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from club.schemas import ClubProfileSchema
|
from club.schemas import ClubProfileSchema
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.markdown import markdown
|
|
||||||
|
|
||||||
|
|
||||||
class NewsDateFilterSchema(FilterSchema):
|
class NewsDateFilterSchema(FilterSchema):
|
||||||
before: datetime | None = Field(None, q="end_date__lt")
|
before: Annotated[datetime | None, FilterLookup("end_date__lt")] = None
|
||||||
after: datetime | None = Field(None, q="start_date__gt")
|
after: Annotated[datetime | None, FilterLookup("start_date__gt")] = None
|
||||||
club_id: int | None = Field(None, q="news__club_id")
|
club_id: Annotated[int | None, FilterLookup("news__club_id")] = None
|
||||||
news_id: int | None = None
|
news_id: int | None = None
|
||||||
is_published: bool | None = Field(None, q="news__is_published")
|
is_published: Annotated[bool | None, FilterLookup("news__is_published")] = None
|
||||||
title: str | None = Field(None, q="news__title__icontains")
|
title: Annotated[str | None, FilterLookup("news__title__icontains")] = None
|
||||||
|
|
||||||
|
|
||||||
class NewsSchema(ModelSchema):
|
class NewsSchema(ModelSchema):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { makeUrl } from "#core:utils/api";
|
import {
|
||||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
Calendar,
|
||||||
import { Calendar, type EventClickArg } from "@fullcalendar/core";
|
type EventClickArg,
|
||||||
|
type EventContentArg,
|
||||||
|
type EventInput,
|
||||||
|
} from "@fullcalendar/core";
|
||||||
import type { EventImpl } from "@fullcalendar/core/internal";
|
import type { EventImpl } from "@fullcalendar/core/internal";
|
||||||
import enLocale from "@fullcalendar/core/locales/en-gb";
|
import enLocale from "@fullcalendar/core/locales/en-gb";
|
||||||
import frLocale from "@fullcalendar/core/locales/fr";
|
import frLocale from "@fullcalendar/core/locales/fr";
|
||||||
@@ -8,6 +11,8 @@ 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 { type HTMLTemplateResult, html, render } from "lit-html";
|
import { type HTMLTemplateResult, html, render } from "lit-html";
|
||||||
|
import { type GenericEndpoint, makeUrl } from "#core:utils/api";
|
||||||
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||||
import {
|
import {
|
||||||
calendarCalendarInternal,
|
calendarCalendarInternal,
|
||||||
calendarCalendarUnpublished,
|
calendarCalendarUnpublished,
|
||||||
@@ -19,21 +24,28 @@ import {
|
|||||||
@registerComponent("ics-calendar")
|
@registerComponent("ics-calendar")
|
||||||
export class IcsCalendar extends inheritHtmlElement("div") {
|
export class IcsCalendar extends inheritHtmlElement("div") {
|
||||||
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
|
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
|
||||||
private calendar: Calendar;
|
private calendar!: Calendar;
|
||||||
private locale = "en";
|
private locale? = "en";
|
||||||
private canModerate = false;
|
private canModerate? = false;
|
||||||
private canDelete = false;
|
private canDelete? = false;
|
||||||
private helpUrl = "";
|
private helpUrl? = "";
|
||||||
|
|
||||||
|
// Hack variable to detect recurring events
|
||||||
|
// The underlying ics library doesn't include any info about rrules
|
||||||
|
// That's why we have to detect those events ourselves
|
||||||
|
private recurrenceMap: Map<string, EventImpl> = new Map();
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||||
if (name === "locale") {
|
if (name === "locale") {
|
||||||
this.locale = newValue;
|
this.locale = newValue;
|
||||||
}
|
}
|
||||||
if (name === "can_moderate") {
|
if (name === "can_moderate") {
|
||||||
this.canModerate = newValue.toLowerCase() === "true";
|
this.canModerate =
|
||||||
|
typeof newValue === "string" && newValue.toLowerCase() === "true";
|
||||||
}
|
}
|
||||||
if (name === "can_delete") {
|
if (name === "can_delete") {
|
||||||
this.canDelete = newValue.toLowerCase() === "true";
|
this.canDelete =
|
||||||
|
typeof newValue === "string" && newValue.toLowerCase() === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === "ics-help-url") {
|
if (name === "ics-help-url") {
|
||||||
@@ -89,12 +101,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
.toString()
|
.toString()
|
||||||
.split("/")
|
.split("/")
|
||||||
.filter((s) => s) // Remove blank characters
|
.filter((s) => s) // Remove blank characters
|
||||||
.pop(),
|
.pop() as string,
|
||||||
|
10,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshEvents() {
|
refreshEvents() {
|
||||||
this.click(); // Remove focus from popup
|
this.click(); // Remove focus from popup
|
||||||
|
this.recurrenceMap.clear(); // Avoid double detection of the same non recurring event
|
||||||
this.calendar.refetchEvents();
|
this.calendar.refetchEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,20 +166,33 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
this.refreshEvents();
|
this.refreshEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEventSources() {
|
async getEventSources(): Promise<EventInput[]> {
|
||||||
|
const tagRecurringEvents = (eventData: EventImpl) => {
|
||||||
|
// This functions tags events with a similar event url
|
||||||
|
// We rely on the fact that the event url is always the same
|
||||||
|
// for recurring events and always different for single events
|
||||||
|
const firstEvent = this.recurrenceMap.get(eventData.url);
|
||||||
|
if (firstEvent !== undefined) {
|
||||||
|
eventData.extendedProps.isRecurring = true;
|
||||||
|
firstEvent.extendedProps.isRecurring = true; // Don't forget the first event
|
||||||
|
}
|
||||||
|
this.recurrenceMap.set(eventData.url, eventData);
|
||||||
|
};
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: `${await makeUrl(calendarCalendarInternal)}`,
|
url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`,
|
||||||
format: "ics",
|
format: "ics",
|
||||||
className: "internal",
|
className: "internal",
|
||||||
cache: false,
|
cache: false,
|
||||||
|
eventDataTransform: tagRecurringEvents,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${await makeUrl(calendarCalendarUnpublished)}`,
|
url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`,
|
||||||
format: "ics",
|
format: "ics",
|
||||||
color: "red",
|
color: "red",
|
||||||
className: "unpublished",
|
className: "unpublished",
|
||||||
cache: false,
|
cache: false,
|
||||||
|
eventDataTransform: tagRecurringEvents,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -193,7 +220,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
${event.title}
|
${event.title}
|
||||||
</h4>
|
</h4>
|
||||||
<span class="event-details-row-content">
|
<span class="event-details-row-content">
|
||||||
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
|
${this.formatDate(event.start as Date)} - ${this.formatDate(event.end as Date)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -231,7 +258,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
const buttons = [] as HTMLTemplateResult[];
|
const buttons = [] as HTMLTemplateResult[];
|
||||||
|
|
||||||
if (this.canModerate) {
|
if (this.canModerate) {
|
||||||
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
|
if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) {
|
||||||
const button = html`
|
const button = html`
|
||||||
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
|
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
|
||||||
<i class="fa fa-check"></i>${gettext("Publish")}
|
<i class="fa fa-check"></i>${gettext("Publish")}
|
||||||
@@ -318,9 +345,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
button.classList.remove("text-copied");
|
button.classList.remove("text-copied");
|
||||||
}
|
}
|
||||||
button.setAttribute("tooltip", gettext("Link copied"));
|
button.setAttribute("tooltip", gettext("Link copied"));
|
||||||
navigator.clipboard.writeText(
|
await navigator.clipboard.writeText(
|
||||||
new URL(
|
new URL(
|
||||||
await makeUrl(calendarCalendarInternal),
|
await makeUrl(calendarCalendarInternal as GenericEndpoint),
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
).toString(),
|
).toString(),
|
||||||
);
|
);
|
||||||
@@ -361,6 +388,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
event.jsEvent.preventDefault();
|
event.jsEvent.preventDefault();
|
||||||
this.createEventDetailPopup(event);
|
this.createEventDetailPopup(event);
|
||||||
},
|
},
|
||||||
|
eventClassNames: (classNamesEvent: EventContentArg) => {
|
||||||
|
const classes: string[] = [];
|
||||||
|
if (classNamesEvent.event.extendedProps?.isRecurring) {
|
||||||
|
classes.push("recurring");
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
this.calendar.render();
|
this.calendar.render();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { exportToHtml } from "#core:utils/globals";
|
import {
|
||||||
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
|
newsDeleteNews,
|
||||||
|
newsFetchNewsDates,
|
||||||
|
newsPublishNews,
|
||||||
|
type PaginatedResponseSchemaNewsDateSchema,
|
||||||
|
} from "#openapi";
|
||||||
|
|
||||||
// This will be used in jinja templates,
|
// This will be used in jinja templates,
|
||||||
// so we cannot use real enums as those are purely an abstraction of Typescript
|
// so we cannot use real enums as those are purely an abstraction of Typescript
|
||||||
@@ -13,7 +17,8 @@ const AlertState = {
|
|||||||
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
|
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
|
||||||
DISPLAYED: 4, // When published at page generation
|
DISPLAYED: 4, // When published at page generation
|
||||||
};
|
};
|
||||||
exportToHtml("AlertState", AlertState);
|
// biome-ignore lint/style/useNamingConvention: it's an enum, PascalCase is better
|
||||||
|
Object.assign(window, { AlertState });
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("moderationAlert", (newsId: number) => ({
|
Alpine.data("moderationAlert", (newsId: number) => ({
|
||||||
@@ -64,7 +69,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// biome-ignore lint/style/useNamingConvention: api is snake-case
|
// biome-ignore lint/style/useNamingConvention: api is snake-case
|
||||||
query: { news_id: this.newsId, page: 1, page_size: 1 },
|
query: { news_id: this.newsId, page: 1, page_size: 1 },
|
||||||
});
|
});
|
||||||
return response.data.count;
|
|
||||||
|
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
|
||||||
},
|
},
|
||||||
|
|
||||||
weeklyEventWarningMessage(nbEvents: number): string {
|
weeklyEventWarningMessage(nbEvents: number): string {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
|
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
|
||||||
|
|
||||||
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
|
type ParsedNewsDateSchema = Omit<NewsDateSchema, "start_date" | "end_date"> & {
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||||
start_date: Date;
|
start_date: Date;
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||||
end_date: Date;
|
end_date: Date;
|
||||||
}
|
};
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
|
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
|
||||||
@@ -32,6 +32,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
page_size: this.pageSize,
|
page_size: this.pageSize,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (response.response === undefined || response.data === undefined) {
|
||||||
|
// response may be undefined, because error may be
|
||||||
|
// from building the request object itself or from a network error
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.response.status === 404) {
|
if (response.response.status === 404) {
|
||||||
this.hasNext = false;
|
this.hasNext = false;
|
||||||
} else if (response.data.next === null) {
|
} else if (response.data.next === null) {
|
||||||
@@ -44,7 +49,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
groupedDates(): Record<string, NewsDateSchema[]> {
|
groupedDates(): Record<string, ParsedNewsDateSchema[]> {
|
||||||
return this.newsDates
|
return this.newsDates
|
||||||
.map(
|
.map(
|
||||||
(date: NewsDateSchema): ParsedNewsDateSchema => ({
|
(date: NewsDateSchema): ParsedNewsDateSchema => ({
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
--event-details-border-radius: 4px;
|
--event-details-border-radius: 4px;
|
||||||
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
||||||
--event-details-max-width: 600px;
|
--event-details-max-width: 600px;
|
||||||
|
--event-recurring-internal-color: #6f69cd;
|
||||||
|
--event-recurring-unpublished-color: orange;
|
||||||
}
|
}
|
||||||
|
|
||||||
ics-calendar {
|
ics-calendar {
|
||||||
@@ -146,4 +148,29 @@ ics-calendar {
|
|||||||
.tooltip.calendar-copy-tooltip.text-copied {
|
.tooltip.calendar-copy-tooltip.text-copied {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 500ms ease-out;
|
transition: opacity 500ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We have to override the color set by the lib in the html
|
||||||
|
// Hence the !important tag everywhere
|
||||||
|
.internal.recurring {
|
||||||
|
.fc-daygrid-event-dot {
|
||||||
|
border-color: var(--event-recurring-internal-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fc-daygrid-block-event {
|
||||||
|
background-color: var(--event-recurring-internal-color) !important;
|
||||||
|
border-color: var(--event-recurring-internal-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpublished.recurring {
|
||||||
|
.fc-daygrid-event-dot {
|
||||||
|
border-color: var(--event-recurring-unpublished-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fc-daygrid-block-event {
|
||||||
|
background-color: var(--event-recurring-unpublished-color) !important;
|
||||||
|
border-color: var(--event-recurring-unpublished-color) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#news {
|
#news {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -15,9 +16,13 @@
|
|||||||
#right_column {
|
#right_column {
|
||||||
flex: 20%;
|
flex: 20%;
|
||||||
margin: 3.2px;
|
margin: 3.2px;
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
|
||||||
|
@media screen and (min-width: 800px) {
|
||||||
|
max-width: 20%;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#left_column {
|
#left_column {
|
||||||
@@ -26,12 +31,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
background: $second-color;
|
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
background: lighten($second-color, 5%);
|
||||||
padding: 0.4em;
|
box-shadow: var(--box-shadow);
|
||||||
|
padding: .75rem;
|
||||||
margin: 0 0 0.5em 0;
|
margin: 0 0 0.5em 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
&:not(:first-of-type) {
|
||||||
margin: 2em 0 1em 0;
|
margin: 2em 0 1em 0;
|
||||||
@@ -39,12 +46,11 @@
|
|||||||
|
|
||||||
.feed {
|
.feed {
|
||||||
float: right;
|
float: right;
|
||||||
color: #f26522;
|
color: #e25512;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $small-devices) {
|
@media screen and (max-width: 800px) {
|
||||||
|
|
||||||
#left_column,
|
#left_column,
|
||||||
#right_column {
|
#right_column {
|
||||||
flex: 100%;
|
flex: 100%;
|
||||||
@@ -57,6 +63,7 @@
|
|||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
|
margin-top: 1em;
|
||||||
|
|
||||||
#load-more-news-button {
|
#load-more-news-button {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -73,18 +80,14 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: white;
|
background: white;
|
||||||
font-size: 70%;
|
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
|
font-size: 85%;
|
||||||
h3 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#links_content {
|
#links_content {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
min-height: 20em;
|
min-height: 20em;
|
||||||
padding-bottom: 1em;
|
padding: 1em;
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
@@ -97,23 +100,10 @@
|
|||||||
li {
|
li {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
|
|
||||||
.fa-facebook {
|
|
||||||
color: $faceblue;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa-discord {
|
|
||||||
color: $discordblurple;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa-square-instagram::before {
|
|
||||||
background: $instagradient;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
i {
|
||||||
width: 25px;
|
width: 25px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
margin-right: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +111,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#birthdays_content {
|
#birthdays_content {
|
||||||
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
|
padding: 1em;
|
||||||
|
|
||||||
ul.birthdays_year {
|
ul.birthdays_year {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
||||||
>li {
|
> li {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
|
||||||
&:nth-child(even) {
|
&:nth-child(even) {
|
||||||
@@ -135,8 +128,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin: 0;
|
margin: .5em 0 0 1em;
|
||||||
margin-left: 1em;
|
|
||||||
list-style-type: square;
|
list-style-type: square;
|
||||||
list-style-position: inside;
|
list-style-position: inside;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -150,9 +142,13 @@
|
|||||||
/* EVENTS TODAY AND NEXT FEW DAYS */
|
/* EVENTS TODAY AND NEXT FEW DAYS */
|
||||||
.news_events_group {
|
.news_events_group {
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
margin-left: 1em;
|
margin-left: 0;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
|
||||||
|
@media screen and (max-width: $small-devices) {
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.news_events_group_date {
|
.news_events_group_date {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
padding: 0.6em;
|
padding: 0.6em;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
|
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
|
||||||
</h3>
|
</h3>
|
||||||
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
|
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
|
||||||
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
|
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
|
||||||
<i class="fa fa-plus"></i>
|
<i class="fa fa-plus"></i>
|
||||||
{% trans %}Create news{% endtrans %}
|
{% trans %}Create news{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<i class="fa-solid fa-graduation-cap fa-xl"></i>
|
<i class="fa-solid fa-graduation-cap fa-xl"></i>
|
||||||
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
|
<a href="{{ url("pedagogy:guide") }}">{% trans %}UE Guide{% endtrans %}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<i class="fa-solid fa-calendar-days fa-xl"></i>
|
<i class="fa-solid fa-calendar-days fa-xl"></i>
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
||||||
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<title>{% trans %}Slideshow{% endtrans %}</title>
|
<title>{% trans %}Slideshow{% endtrans %}</title>
|
||||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||||
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
||||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
<script type="module" src="{{ static('bundled/base-bundle-index.ts') }}"></script>
|
||||||
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="slideshow([
|
<body x-data="slideshow([
|
||||||
|
|||||||
+1
-12
@@ -1,8 +1,8 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from aemark import markdown
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -14,20 +14,9 @@ from pytest_django.asserts import assertNumQueries
|
|||||||
|
|
||||||
from com.ics_calendar import IcsCalendar
|
from com.ics_calendar import IcsCalendar
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.markdown import markdown
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MockResponse:
|
|
||||||
ok: bool
|
|
||||||
value: str
|
|
||||||
|
|
||||||
@property
|
|
||||||
def content(self):
|
|
||||||
return self.value.encode("utf8")
|
|
||||||
|
|
||||||
|
|
||||||
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
|
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
|
||||||
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
|
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
|
||||||
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):
|
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from model_bakery import baker
|
|||||||
|
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, Notification, User
|
from core.models import Group, Notification, SithFile, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -18,6 +18,7 @@ def test_notification_created():
|
|||||||
past_news = baker.make(News, is_published=False)
|
past_news = baker.make(News, is_published=False)
|
||||||
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
|
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
|
||||||
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
|
SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete()
|
||||||
com_admin_group.users.all().delete()
|
com_admin_group.users.all().delete()
|
||||||
Notification.objects.all().delete()
|
Notification.objects.all().delete()
|
||||||
com_admin = baker.make(User, groups=[com_admin_group])
|
com_admin = baker.make(User, groups=[com_admin_group])
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import AnonymousUser, Group, User
|
from core.models import AnonymousUser, Group, User
|
||||||
@@ -214,7 +214,8 @@ class TestNewsCreation(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
cls.user = subscriber_user.make()
|
cls.user = subscriber_user.make()
|
||||||
baker.make(Membership, user=cls.user, club=cls.club, role=5)
|
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
||||||
|
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|||||||
+5
-5
@@ -170,7 +170,7 @@ class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
|
|||||||
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"
|
permission_required = "com.change_news"
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form) # Does the saving part
|
response = super().form_valid(form) # Does the saving part
|
||||||
@@ -240,12 +240,12 @@ class NewsListView(TemplateView):
|
|||||||
if not self.request.user.has_perm("core.view_user"):
|
if not self.request.user.has_perm("core.view_user"):
|
||||||
return []
|
return []
|
||||||
return itertools.groupby(
|
return itertools.groupby(
|
||||||
User.objects.filter(
|
User.objects.viewable_by(self.request.user)
|
||||||
|
.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,
|
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,
|
||||||
)
|
)
|
||||||
@@ -503,7 +503,7 @@ class WeekmailArticleCreateView(CreateView):
|
|||||||
self.object = form.instance
|
self.object = form.instance
|
||||||
form.is_valid() # Valid a first time to populate club field
|
form.is_valid() # Valid a first time to populate club field
|
||||||
m = form.instance.club.get_membership_for(request.user)
|
m = form.instance.club.get_membership_for(request.user)
|
||||||
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
if m is None or not m.role.is_board:
|
||||||
form.add_error(
|
form.add_error(
|
||||||
"club",
|
"club",
|
||||||
ValidationError(
|
ValidationError(
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
"scrub_pict",
|
"scrub_pict",
|
||||||
"user_permissions",
|
"user_permissions",
|
||||||
"groups",
|
"groups",
|
||||||
|
"whitelisted_users",
|
||||||
)
|
)
|
||||||
inlines = (UserBanInline,)
|
inlines = (UserBanInline,)
|
||||||
search_fields = ["first_name", "last_name", "username"]
|
search_fields = ["first_name", "last_name", "username"]
|
||||||
@@ -74,9 +75,19 @@ class UserBanAdmin(admin.ModelAdmin):
|
|||||||
autocomplete_fields = ("user", "ban_group")
|
autocomplete_fields = ("user", "ban_group")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupInline(admin.TabularInline):
|
||||||
|
model = Group.permissions.through
|
||||||
|
readonly_fields = ("group",)
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Permission)
|
@admin.register(Permission)
|
||||||
class PermissionAdmin(admin.ModelAdmin):
|
class PermissionAdmin(admin.ModelAdmin):
|
||||||
search_fields = ("codename",)
|
search_fields = ("codename",)
|
||||||
|
inlines = (GroupInline,)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Page)
|
@admin.register(Page)
|
||||||
|
|||||||
+20
-13
@@ -1,6 +1,6 @@
|
|||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
import annotated_types
|
from annotated_types import Ge, Le, MinLen
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -26,8 +26,10 @@ from core.schemas import (
|
|||||||
UserFilterSchema,
|
UserFilterSchema,
|
||||||
UserProfileSchema,
|
UserProfileSchema,
|
||||||
UserSchema,
|
UserSchema,
|
||||||
|
ValidationErrorSchema,
|
||||||
)
|
)
|
||||||
from core.templatetags.renderer import markdown
|
from core.templatetags.renderer import markdown
|
||||||
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/markdown")
|
@api_controller("/markdown")
|
||||||
@@ -44,7 +46,7 @@ class UploadController(ControllerBase):
|
|||||||
"/image",
|
"/image",
|
||||||
response={
|
response={
|
||||||
200: UploadedFileSchema,
|
200: UploadedFileSchema,
|
||||||
422: dict[Literal["detail"], list[dict[str, Any]]],
|
422: ValidationErrorSchema,
|
||||||
403: dict[Literal["detail"], str],
|
403: dict[Literal["detail"], str],
|
||||||
},
|
},
|
||||||
permissions=[HasPerm("core.add_quickuploadimage")],
|
permissions=[HasPerm("core.add_quickuploadimage")],
|
||||||
@@ -72,9 +74,9 @@ class MailingListController(ControllerBase):
|
|||||||
|
|
||||||
@api_controller("/user")
|
@api_controller("/user")
|
||||||
class UserController(ControllerBase):
|
class UserController(ControllerBase):
|
||||||
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
|
@route.get("", response=list[UserProfileSchema])
|
||||||
def fetch_profiles(self, pks: Query[set[int]]):
|
def fetch_profiles(self, pks: Query[set[int]]):
|
||||||
return User.objects.filter(pk__in=pks)
|
return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks)
|
||||||
|
|
||||||
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
|
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
|
||||||
def fetch_user(self, user_id: int):
|
def fetch_user(self, user_id: int):
|
||||||
@@ -85,13 +87,18 @@ class UserController(ControllerBase):
|
|||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[UserProfileSchema],
|
response=PaginatedResponseSchema[UserProfileSchema],
|
||||||
url_name="search_users",
|
url_name="search_users",
|
||||||
permissions=[CanAccessLookup],
|
# logged in barmen aren't authenticated stricto sensu, so no auth here
|
||||||
|
auth=None,
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=20)
|
@paginate(PageNumberPaginationExtra, page_size=20)
|
||||||
def search_users(self, filters: Query[UserFilterSchema]):
|
def search_users(self, filters: Query[UserFilterSchema]):
|
||||||
return filters.filter(
|
qs = User.objects
|
||||||
User.objects.order_by(F("last_login").desc(nulls_last=True))
|
# the logged in barmen can see all users (even the hidden one),
|
||||||
)
|
# because they have a temporary administrative function during
|
||||||
|
# which they may have to deal with hidden users
|
||||||
|
if not is_logged_in_counter(self.context.request):
|
||||||
|
qs = qs.viewable_by(self.context.request.user)
|
||||||
|
return filters.filter(qs.order_by(F("last_login").desc(nulls_last=True)))
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/file")
|
@api_controller("/file")
|
||||||
@@ -103,7 +110,7 @@ class SithFileController(ControllerBase):
|
|||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
|
def search_files(self, search: Annotated[str, MinLen(1)]):
|
||||||
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
|
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,11 +123,11 @@ class GroupController(ControllerBase):
|
|||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
|
def search_group(self, search: Annotated[str, MinLen(1)]):
|
||||||
return Group.objects.filter(name__icontains=search).values()
|
return Group.objects.filter(name__icontains=search).order_by("name").values()
|
||||||
|
|
||||||
|
|
||||||
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
|
DepthValue = Annotated[int, Ge(0), Le(10)]
|
||||||
DEFAULT_DEPTH = 4
|
DEFAULT_DEPTH = 4
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
|
|||||||
return False
|
return False
|
||||||
if super().has_permission():
|
if super().has_permission():
|
||||||
return True
|
return True
|
||||||
return self.club is not None and any(
|
return (
|
||||||
g.id == self.club.board_group_id for g in self.request.user.cached_groups
|
self.club is not None
|
||||||
|
and self.club.board_group_id in self.request.user.all_groups
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import localdate, now
|
from django.utils.timezone import localdate, now
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe, related
|
from model_bakery.recipe import Recipe, foreign_key, related
|
||||||
|
|
||||||
from club.models import Membership
|
from club.models import ClubRole, Membership
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
@@ -52,7 +52,9 @@ ae_board_membership = Recipe(
|
|||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club_id=settings.SITH_MAIN_CLUB_ID,
|
club_id=settings.SITH_MAIN_CLUB_ID,
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
role=foreign_key(
|
||||||
|
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
board_user = Recipe(
|
board_user = Recipe(
|
||||||
|
|||||||
+35
-36
@@ -2,12 +2,9 @@
|
|||||||
<h1>Markdown-AE Documentation</h1>
|
<h1>Markdown-AE Documentation</h1>
|
||||||
<p>Le Markdown le plus standard se trouve documenté ici:
|
<p>Le Markdown le plus standard se trouve documenté ici:
|
||||||
<a href="https://www.markdownguide.org/basic-syntax">https://www.markdownguide.org/basic-syntax</a>.<br />
|
<a href="https://www.markdownguide.org/basic-syntax">https://www.markdownguide.org/basic-syntax</a>.<br />
|
||||||
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
|
Si cette page n’est pas exhaustive vis à vis de la syntaxe du site AE,
|
||||||
elle a au moins le mérite de bien documenter le Markdown original.</p>
|
elle a au moins le mérite de bien documenter le Markdown original.</p>
|
||||||
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/lepture/mistune">mistune</a>.<br />
|
<p>Le réel parseur du site AE est une version tunée de <a href="https://github.com/kivikakk/comrak">comrak</a>.</p>
|
||||||
Les plus aventureux pourront aller lire ses <a href="https://github.com/lepture/mistune/blob/master/tests/fixtures">tests</a>
|
|
||||||
afin d'en connaître la syntaxe le plus finement possible.<br />
|
|
||||||
En pratique, cette page devrait déjà résumer une bonne partie.</p>
|
|
||||||
<h2>Basique</h2>
|
<h2>Basique</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li>
|
<li>Mettre le texte en <strong>gras</strong> : <code>**texte**</code></li>
|
||||||
@@ -15,8 +12,8 @@ En pratique, cette page devrait déjà résumer une bonne partie.</p>
|
|||||||
<li><u>Souligner</u> le texte : <code>__texte__</code></li>
|
<li><u>Souligner</u> le texte : <code>__texte__</code></li>
|
||||||
<li><del>Barrer du texte</del> : <code>~~texte~~</code></li>
|
<li><del>Barrer du texte</del> : <code>~~texte~~</code></li>
|
||||||
<li>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li>
|
<li>On peut bien sûr tout <del><em><strong><u>combiner</u></strong></em></del> : <code>~~***__texte__***~~</code></li>
|
||||||
<li>Mettre du texte^en exposant^ : <code><sup>texte</sup></code></li>
|
<li>Mettre du texte<sup>en exposant</sup> : <code><sup>texte</sup></code></li>
|
||||||
<li>Mettre du texte~en indice~ : <code><sub>texte</sub></code></li>
|
<li>Mettre du texte<sub>en indice</sub> : <code><sub>texte</sub></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
<h2>Liens</h2>
|
<h2>Liens</h2>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -28,10 +25,10 @@ En pratique, cette page devrait déjà résumer une bonne partie.</p>
|
|||||||
</ul>
|
</ul>
|
||||||
<p><a href="http://www.site.com">nom du lien</a></p>
|
<p><a href="http://www.site.com">nom du lien</a></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Les liens peuvent être internes au site de l'AE, on peut dès lors éviter d'entrer
|
<li>Les liens peuvent être internes au site de l’AE, on peut dès lors éviter d’entrer
|
||||||
l'adresse complète d'une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
|
l’adresse complète d’une page : <code>[nom du lien](page://nomDeLaPage)</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="/page/nomDeLaPage/">nom du lien</a></p>
|
<p><a href="/page/nomDeLaPage">nom du lien</a></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>On peut également utiliser une image pour les liens :
|
<li>On peut également utiliser une image pour les liens :
|
||||||
<code>[nom du lien](options)</code></li>
|
<code>[nom du lien](options)</code></li>
|
||||||
@@ -94,25 +91,25 @@ etc...
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Titre</th>
|
<th>Titre</th>
|
||||||
<th>Titre2</th>
|
<th>Titre2</th>
|
||||||
<th>Titre3</th>
|
<th>Titre3</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
<td>test</td>
|
<td>test</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p>L'alignement dans les cellules est géré comme suit, avec les ':' sur la ligne en dessous du titre:</p>
|
<p>L’alignement dans les cellules est géré comme suit, avec les ‘:’ sur la ligne en dessous du titre:</p>
|
||||||
<pre><code>| Titre | Titre2 | Titre3 |
|
<pre><code>| Titre | Titre2 | Titre3 |
|
||||||
|:-------|:------:|-------:|
|
|:-------|:------:|-------:|
|
||||||
| gauche | centre | droite |
|
| gauche | centre | droite |
|
||||||
@@ -120,16 +117,16 @@ etc...
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="text-align:left">Titre</th>
|
<th align="left">Titre</th>
|
||||||
<th style="text-align:center">Titre2</th>
|
<th align="center">Titre2</th>
|
||||||
<th style="text-align:right">Titre3</th>
|
<th align="right">Titre3</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="text-align:left">gauche</td>
|
<td align="left">gauche</td>
|
||||||
<td style="text-align:center">centre</td>
|
<td align="center">centre</td>
|
||||||
<td style="text-align:right">droite</td>
|
<td align="right">droite</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -141,11 +138,11 @@ etc...
|
|||||||

|

|
||||||

|

|
||||||
</code></pre>
|
</code></pre>
|
||||||
<p><img src="/static/core/img/logo.png" alt="image à 50%" title="Image à 50%" style="width:50%;" /><br />
|
<p><img src="/static/core/img/logo.png" style="width:50%" alt="image à 50%" title="Image à 50%" /><br />
|
||||||
Image à 50% de la largeur de la page.</p>
|
Image à 50% de la largeur de la page.</p>
|
||||||
<p><img src="/static/core/img/logo.png" alt="image de 350 pixels de large" title="Image de 350 pixels" style="width:350px;" /><br />
|
<p><img src="/static/core/img/logo.png" style="width:350px" alt="image de 350 pixels de large" title="Image de 350 pixels" /><br />
|
||||||
Image de 350 pixels de large.</p>
|
Image de 350 pixels de large.</p>
|
||||||
<p><img src="/static/core/img/logo.png" alt="image de 350x100 pixels" title="Image de 350x100 pixels" style="width:350px;height:100px;" /><br />
|
<p><img src="/static/core/img/logo.png" style="width:350px;height:100px" alt="image de 350x100 pixels" title="Image de 350x100 pixels" /><br />
|
||||||
Image de 350x100 pixels.</p>
|
Image de 350x100 pixels.</p>
|
||||||
<p>(devrait pouvoir détecter si vidéo ou non)</p>
|
<p>(devrait pouvoir détecter si vidéo ou non)</p>
|
||||||
<h2>Blocs de citations</h2>
|
<h2>Blocs de citations</h2>
|
||||||
@@ -159,9 +156,9 @@ Image de 350x100 pixels.</p>
|
|||||||
un bloc de
|
un bloc de
|
||||||
citation</p>
|
citation</p>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<p>Il est possible d'intégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
|
<p>Il est possible d’intégrer de la syntaxe Markdown-AE dans un tel bloc.</p>
|
||||||
<h2>Note de bas de page</h2>
|
<h2>Note de bas de page</h2>
|
||||||
<p>On les crée comme ça<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup>:</p>
|
<p>On les crée comme ça<sup class="footnote-ref"><a href="#fn-key" id="fnref-key" data-footnote-ref>1</a></sup>:</p>
|
||||||
<pre><code>Je fais une note[^clef].
|
<pre><code>Je fais une note[^clef].
|
||||||
|
|
||||||
[^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas
|
[^clef]: je note ensuite où je veux le contenu de ma clef qui apparaîtra quand même en bas
|
||||||
@@ -175,13 +172,15 @@ citation</p>
|
|||||||
</code></pre>
|
</code></pre>
|
||||||
<h2>Échapper des caractères</h2>
|
<h2>Échapper des caractères</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Il est possible d'ignorer un caractère spécial en l'échappant à l'aide d'un \</li>
|
<li>Il est possible d’ignorer un caractère spécial en l’échappant à l’aide d’un \</li>
|
||||||
<li>L'échappement de blocs de codes complet se fera à l'aide de balises <nosyntax></nosyntax></li>
|
<li>L’échappement de blocs de codes complet se fera à l’aide de balises <nosyntax></nosyntax></li>
|
||||||
</ul>
|
</ul>
|
||||||
<h2>Autres (hérité de l'ancien wiki)</h2>
|
<h2>Autres (hérité de l’ancien wiki)</h2>
|
||||||
<p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p>
|
<p>Une ligne peut être créée avec une ligne contenant 4 tirets (<code>----</code>).</p>
|
||||||
<section class="footnotes">
|
<section class="footnotes" data-footnotes>
|
||||||
<ol>
|
<ol>
|
||||||
<li id="fn-1"><p>ceci est le contenu de ma clef<a href="#fnref-1" class="footnote">↩</a></p></li>
|
<li id="fn-key">
|
||||||
|
<p>ceci est le contenu de ma clef <a href="#fnref-key" class="footnote-backref" data-footnote-backref data-footnote-backref-idx="1" aria-label="Back to reference 1">↩</a></p>
|
||||||
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ https://www.markdownguide.org/basic-syntax.
|
|||||||
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
|
Si cette page n'est pas exhaustive vis à vis de la syntaxe du site AE,
|
||||||
elle a au moins le mérite de bien documenter le Markdown original.
|
elle a au moins le mérite de bien documenter le Markdown original.
|
||||||
|
|
||||||
Le réel parseur du site AE est une version tunée de [mistune](https://github.com/lepture/mistune).
|
Le réel parseur du site AE est une version tunée de [comrak](https://github.com/kivikakk/comrak).
|
||||||
Les plus aventureux pourront aller lire ses [tests](https://github.com/lepture/mistune/blob/master/tests/fixtures)
|
|
||||||
afin d'en connaître la syntaxe le plus finement possible.
|
|
||||||
En pratique, cette page devrait déjà résumer une bonne partie.
|
|
||||||
|
|
||||||
## Basique
|
## Basique
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ class Command(BaseCommand):
|
|||||||
return None
|
return None
|
||||||
return xapian.version_string()
|
return xapian.version_string()
|
||||||
|
|
||||||
def _desired_version(self) -> str:
|
def _desired_version(self) -> tuple[str, str, str]:
|
||||||
with open(
|
with open(
|
||||||
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
|
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
|
||||||
) as f:
|
) as f:
|
||||||
pyproject = tomli.load(f)
|
pyproject = tomli.load(f)
|
||||||
return pyproject["tool"]["xapian"]["version"]
|
return (
|
||||||
|
pyproject["tool"]["xapian"]["version"],
|
||||||
|
pyproject["tool"]["xapian"]["core-sha256"],
|
||||||
|
pyproject["tool"]["xapian"]["bindings-sha256"],
|
||||||
|
)
|
||||||
|
|
||||||
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):
|
||||||
@@ -53,7 +57,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
desired = self._desired_version()
|
desired, core_checksum, bindings_checksum = self._desired_version()
|
||||||
if desired == self._current_version():
|
if desired == self._current_version():
|
||||||
if not force:
|
if not force:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
@@ -65,7 +69,12 @@ class Command(BaseCommand):
|
|||||||
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
|
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
|
||||||
)
|
)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[str(Path(__file__).parent / "install_xapian.sh"), desired],
|
[
|
||||||
|
str(Path(__file__).parent / "install_xapian.sh"),
|
||||||
|
desired,
|
||||||
|
core_checksum,
|
||||||
|
bindings_checksum,
|
||||||
|
],
|
||||||
env=dict(os.environ),
|
env=dict(os.environ),
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
|
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
|
||||||
# first argument of the script is Xapian version (e.g. 1.2.19)
|
# first argument of the script is Xapian version (e.g. 1.2.19)
|
||||||
|
# second argument of the script is core sha256
|
||||||
|
# second argument of the script is binding sha256
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
|
CORE_SHA256="$2"
|
||||||
|
BINDINGS_SHA256="$3"
|
||||||
|
|
||||||
# Cleanup env vars for auto discovery mechanism
|
# Cleanup env vars for auto discovery mechanism
|
||||||
unset CPATH
|
unset CPATH
|
||||||
@@ -21,9 +25,15 @@ BINDINGS=xapian-bindings-$VERSION
|
|||||||
|
|
||||||
# download
|
# download
|
||||||
echo "Downloading source..."
|
echo "Downloading source..."
|
||||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
|
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1
|
||||||
|
|
||||||
|
echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
|
||||||
|
|
||||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
|
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
|
||||||
|
|
||||||
|
echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
|
||||||
|
|
||||||
|
|
||||||
# extract
|
# extract
|
||||||
echo "Extracting source..."
|
echo "Extracting source..."
|
||||||
tar xf "${CORE}.tar.xz"
|
tar xf "${CORE}.tar.xz"
|
||||||
|
|||||||
@@ -22,11 +22,10 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
|
from aemark import markdown
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from core.markdown import markdown
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Output the fully rendered SYNTAX.md file"
|
help = "Output the fully rendered SYNTAX.md file"
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
# details.
|
# details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# 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
|
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from datetime import date, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, NamedTuple
|
from typing import ClassVar, NamedTuple
|
||||||
@@ -33,18 +33,27 @@ from django.core.management.base import BaseCommand
|
|||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import localdate
|
from django.utils.lorem_ipsum import paragraphs
|
||||||
|
from django.utils.timezone import localdate, now
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
|
||||||
from com.ics_calendar import IcsCalendar
|
from com.ics_calendar import IcsCalendar
|
||||||
from com.models import News, NewsDate, Sith, Weekmail
|
from com.models import News, NewsDate, Sith, Weekmail
|
||||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
|
from counter.models import (
|
||||||
from election.models import Candidature, Election, ElectionList, Role
|
Counter,
|
||||||
|
CounterSellers,
|
||||||
|
Price,
|
||||||
|
Product,
|
||||||
|
ProductType,
|
||||||
|
ReturnableProduct,
|
||||||
|
StudentCard,
|
||||||
|
)
|
||||||
|
from election.models import Candidature, Election, ElectionList, Role, Vote
|
||||||
from forum.models import Forum
|
from forum.models import Forum
|
||||||
from pedagogy.models import UV
|
from pedagogy.models import UE
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
@@ -62,6 +71,13 @@ class PopulatedGroups(NamedTuple):
|
|||||||
campus_admin: Group
|
campus_admin: Group
|
||||||
|
|
||||||
|
|
||||||
|
class PopulatedClubs(NamedTuple):
|
||||||
|
ae: Club
|
||||||
|
troll: Club
|
||||||
|
pdf: Club
|
||||||
|
refound: Club
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||||
@@ -110,29 +126,19 @@ class Command(BaseCommand):
|
|||||||
p.save(force_lock=True)
|
p.save(force_lock=True)
|
||||||
|
|
||||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||||
sas = SithFile.objects.create(name="SAS", owner=root)
|
sas = SithFile.objects.create(
|
||||||
main_club = Club.objects.create(
|
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
|
||||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
|
||||||
)
|
|
||||||
main_club.board_group.permissions.add(
|
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_subscription", "add_subscription"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
bar_club = Club.objects.create(
|
|
||||||
id=settings.SITH_PDF_CLUB_ID,
|
|
||||||
name="PdF",
|
|
||||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
|
||||||
)
|
)
|
||||||
|
clubs = self._create_clubs()
|
||||||
|
|
||||||
self.reset_index("club")
|
self.reset_index("club")
|
||||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||||
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
|
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
|
||||||
self.reset_index("counter")
|
self.reset_index("counter")
|
||||||
counters = [
|
counters = [
|
||||||
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
|
||||||
Counter(name="AE", club=main_club, type="OFFICE"),
|
Counter(name="AE", club=clubs.ae, type="OFFICE"),
|
||||||
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
|
||||||
]
|
]
|
||||||
Counter.objects.bulk_create(counters)
|
Counter.objects.bulk_create(counters)
|
||||||
bar_groups = []
|
bar_groups = []
|
||||||
@@ -150,7 +156,8 @@ 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, second=0)
|
self.now = timezone.now().replace(hour=12, second=0)
|
||||||
|
|
||||||
skia = User.objects.create_user(
|
skia = User.objects.create_user(
|
||||||
@@ -314,230 +321,60 @@ class Command(BaseCommand):
|
|||||||
self._create_subscription(tutu)
|
self._create_subscription(tutu)
|
||||||
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
||||||
|
|
||||||
# Clubs
|
Membership.objects.create(
|
||||||
Club.objects.create(
|
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
|
||||||
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
|
|
||||||
)
|
)
|
||||||
guyut = Club.objects.create(
|
|
||||||
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
|
|
||||||
)
|
|
||||||
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
|
|
||||||
troll = Club.objects.create(
|
|
||||||
name="Troll Penché", address="Terre Du Milieu", parent=main_club
|
|
||||||
)
|
|
||||||
refound = Club.objects.create(
|
|
||||||
name="Carte AE", address="Jamais imprimée", parent=main_club
|
|
||||||
)
|
|
||||||
|
|
||||||
Membership.objects.create(user=skia, club=main_club, role=3)
|
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=comunity,
|
user=comunity,
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
start_date=localdate(),
|
start_date=localdate(),
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
role=clubs.pdf.roles.get(name="Membre du bureau"),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=sli,
|
user=sli,
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
role=9,
|
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
|
||||||
description="Padawan Troll",
|
description="Padawan Troll",
|
||||||
start_date=localdate() - timedelta(days=17),
|
start_date=localdate() - timedelta(days=17),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=krophil,
|
user=krophil,
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
role=10,
|
role=clubs.troll.roles.get(name="Président⸱e"),
|
||||||
description="Maitre Troll",
|
description="Maitre Troll",
|
||||||
start_date=localdate() - timedelta(days=200),
|
start_date=localdate() - timedelta(days=200),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=skia,
|
user=skia,
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
role=2,
|
role=clubs.troll.roles.get(name="Membre du bureau"),
|
||||||
description="Grand Ancien Troll",
|
description="Grand Ancien Troll",
|
||||||
start_date=localdate() - timedelta(days=400),
|
start_date=localdate() - timedelta(days=400),
|
||||||
end_date=localdate() - timedelta(days=86),
|
end_date=localdate() - timedelta(days=86),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=richard,
|
user=richard,
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
role=2,
|
role=clubs.troll.roles.get(name="Membre du bureau"),
|
||||||
description="",
|
description="",
|
||||||
start_date=localdate() - timedelta(days=200),
|
start_date=localdate() - timedelta(days=200),
|
||||||
end_date=localdate() - timedelta(days=100),
|
end_date=localdate() - timedelta(days=100),
|
||||||
)
|
)
|
||||||
|
|
||||||
p = ProductType.objects.create(name="Bières bouteilles")
|
self._create_products(groups, clubs)
|
||||||
c = ProductType.objects.create(name="Cotisations")
|
|
||||||
r = ProductType.objects.create(name="Rechargements")
|
|
||||||
verre = ProductType.objects.create(name="Verre")
|
|
||||||
cotis = Product.objects.create(
|
|
||||||
name="Cotis 1 semestre",
|
|
||||||
code="1SCOTIZ",
|
|
||||||
product_type=c,
|
|
||||||
purchase_price="15",
|
|
||||||
selling_price="15",
|
|
||||||
special_selling_price="15",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
cotis2 = Product.objects.create(
|
|
||||||
name="Cotis 2 semestres",
|
|
||||||
code="2SCOTIZ",
|
|
||||||
product_type=c,
|
|
||||||
purchase_price="28",
|
|
||||||
selling_price="28",
|
|
||||||
special_selling_price="28",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
refill = Product.objects.create(
|
|
||||||
name="Rechargement 15 €",
|
|
||||||
code="15REFILL",
|
|
||||||
product_type=r,
|
|
||||||
purchase_price="15",
|
|
||||||
selling_price="15",
|
|
||||||
special_selling_price="15",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
barb = Product.objects.create(
|
|
||||||
name="Barbar",
|
|
||||||
code="BARB",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
cble = Product.objects.create(
|
|
||||||
name="Chimay Bleue",
|
|
||||||
code="CBLE",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
cons = Product.objects.create(
|
|
||||||
name="Consigne Eco-cup",
|
|
||||||
code="CONS",
|
|
||||||
product_type=verre,
|
|
||||||
purchase_price="1",
|
|
||||||
selling_price="1",
|
|
||||||
special_selling_price="1",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
dcons = Product.objects.create(
|
|
||||||
name="Déconsigne Eco-cup",
|
|
||||||
code="DECO",
|
|
||||||
product_type=verre,
|
|
||||||
purchase_price="-1",
|
|
||||||
selling_price="-1",
|
|
||||||
special_selling_price="-1",
|
|
||||||
club=main_club,
|
|
||||||
)
|
|
||||||
cors = Product.objects.create(
|
|
||||||
name="Corsendonk",
|
|
||||||
code="CORS",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
carolus = Product.objects.create(
|
|
||||||
name="Carolus",
|
|
||||||
code="CARO",
|
|
||||||
product_type=p,
|
|
||||||
purchase_price="1.50",
|
|
||||||
selling_price="1.7",
|
|
||||||
special_selling_price="1.6",
|
|
||||||
club=main_club,
|
|
||||||
limit_age=18,
|
|
||||||
)
|
|
||||||
Product.objects.create(
|
|
||||||
name="remboursement",
|
|
||||||
code="REMBOURS",
|
|
||||||
purchase_price="0",
|
|
||||||
selling_price="0",
|
|
||||||
special_selling_price="0",
|
|
||||||
club=refound,
|
|
||||||
)
|
|
||||||
groups.subscribers.products.add(
|
|
||||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
|
||||||
)
|
|
||||||
groups.old_subscribers.products.add(cotis, cotis2)
|
|
||||||
|
|
||||||
mde = Counter.objects.get(name="MDE")
|
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
||||||
mde.products.add(barb, cble, cons, dcons)
|
|
||||||
|
|
||||||
eboutic = Counter.objects.get(name="Eboutic")
|
|
||||||
eboutic.products.add(barb, cotis, cotis2, refill)
|
|
||||||
|
|
||||||
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
|
||||||
|
|
||||||
ReturnableProduct.objects.create(
|
|
||||||
product=cons, returned_product=dcons, max_return=3
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add barman to counter
|
# Add barman to counter
|
||||||
Counter.sellers.through.objects.bulk_create(
|
CounterSellers.objects.bulk_create(
|
||||||
[
|
[
|
||||||
Counter.sellers.through(counter_id=2, user=krophil),
|
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE
|
||||||
Counter.sellers.through(counter=mde, user=skia),
|
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create an election
|
# Create an election
|
||||||
el = Election.objects.create(
|
self._create_elections(groups, clubs, skia, sli, krophil)
|
||||||
title="Élection 2017",
|
|
||||||
description="La roue tourne",
|
|
||||||
start_candidature="1942-06-12 10:28:45+01",
|
|
||||||
end_candidature="2042-06-12 10:28:45+01",
|
|
||||||
start_date="1942-06-12 10:28:45+01",
|
|
||||||
end_date="7942-06-12 10:28:45+01",
|
|
||||||
)
|
|
||||||
el.view_groups.add(groups.public)
|
|
||||||
el.edit_groups.add(main_club.board_group)
|
|
||||||
el.candidature_groups.add(groups.subscribers)
|
|
||||||
el.vote_groups.add(groups.subscribers)
|
|
||||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
|
||||||
listeT = ElectionList.objects.create(title="Troll", election=el)
|
|
||||||
pres = Role.objects.create(
|
|
||||||
election=el, title="Président AE", description="Roi de l'AE"
|
|
||||||
)
|
|
||||||
resp = Role.objects.create(
|
|
||||||
election=el, title="Co Respo Info", max_choice=2, description="Ghetto++"
|
|
||||||
)
|
|
||||||
Candidature.objects.bulk_create(
|
|
||||||
[
|
|
||||||
Candidature(
|
|
||||||
role=resp,
|
|
||||||
user=skia,
|
|
||||||
election_list=liste,
|
|
||||||
program="Refesons le site AE",
|
|
||||||
),
|
|
||||||
Candidature(
|
|
||||||
role=resp,
|
|
||||||
user=sli,
|
|
||||||
election_list=liste,
|
|
||||||
program="Vasy je deviens mon propre adjoint",
|
|
||||||
),
|
|
||||||
Candidature(
|
|
||||||
role=resp,
|
|
||||||
user=krophil,
|
|
||||||
election_list=listeT,
|
|
||||||
program="Le Pôle Troll !",
|
|
||||||
),
|
|
||||||
Candidature(
|
|
||||||
role=pres,
|
|
||||||
user=sli,
|
|
||||||
election_list=listeT,
|
|
||||||
program="En fait j'aime pas l'info, je voulais faire GMC",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Forum
|
# Forum
|
||||||
room = Forum.objects.create(
|
room = Forum.objects.create(
|
||||||
@@ -572,7 +409,7 @@ class Command(BaseCommand):
|
|||||||
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",
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -590,7 +427,7 @@ class Command(BaseCommand):
|
|||||||
content=(
|
content=(
|
||||||
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
|
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
|
||||||
),
|
),
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -606,7 +443,7 @@ class Command(BaseCommand):
|
|||||||
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!",
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -622,7 +459,7 @@ class Command(BaseCommand):
|
|||||||
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!",
|
||||||
club=bar_club,
|
club=clubs.pdf,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -640,7 +477,7 @@ class Command(BaseCommand):
|
|||||||
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!",
|
||||||
club=troll,
|
club=clubs.troll,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -660,20 +497,20 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Create some data for pedagogy
|
# Create some data for pedagogy
|
||||||
|
|
||||||
UV(
|
UE(
|
||||||
code="PA00",
|
code="PA00",
|
||||||
author=User.objects.get(id=0),
|
author=User.objects.get(id=0),
|
||||||
credit_type=settings.SITH_PEDAGOGY_UV_TYPE[3][0],
|
credit_type=settings.SITH_PEDAGOGY_UE_TYPE[3][0],
|
||||||
manager="Laurent HEYBERGER",
|
manager="Laurent HEYBERGER",
|
||||||
semester=settings.SITH_PEDAGOGY_UV_SEMESTER[3][0],
|
semester=settings.SITH_PEDAGOGY_UE_SEMESTER[3][0],
|
||||||
language=settings.SITH_PEDAGOGY_UV_LANGUAGE[0][0],
|
language=settings.SITH_PEDAGOGY_UE_LANGUAGE[0][0],
|
||||||
department=settings.SITH_PROFILE_DEPARTMENTS[-2][0],
|
department=settings.SITH_PROFILE_DEPARTMENTS[-2][0],
|
||||||
credits=5,
|
credits=5,
|
||||||
title="Participation dans une association étudiante",
|
title="Participation dans une association étudiante",
|
||||||
objectives="* Permettre aux étudiants de réaliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.",
|
objectives="* Permettre aux étudiants de réaliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.",
|
||||||
program="""* Semestre précédent proposition d'un projet et d'un cahier des charges
|
program="""* Semestre précédent proposition d'un projet et d'un cahier des charges
|
||||||
* Evaluation par un jury de six membres
|
* Evaluation par un jury de six membres
|
||||||
* Si accord réalisation dans le cadre de l'UV
|
* Si accord réalisation dans le cadre de l'UE
|
||||||
* Compte-rendu de l'expérience
|
* Compte-rendu de l'expérience
|
||||||
* Présentation""",
|
* Présentation""",
|
||||||
skills="""* Gérer un projet associatif ou une action éducative en autonomie:
|
skills="""* Gérer un projet associatif ou une action éducative en autonomie:
|
||||||
@@ -718,8 +555,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
pict.file.name = p.name
|
pict.file.name = p.name
|
||||||
pict.full_clean()
|
pict.full_clean()
|
||||||
pict.generate_thumbnails()
|
pict.generate_thumbnails(save=True)
|
||||||
pict.save()
|
|
||||||
|
|
||||||
img_skia = Picture.objects.get(name="skia.jpg")
|
img_skia = Picture.objects.get(name="skia.jpg")
|
||||||
img_sli = Picture.objects.get(name="sli.jpg")
|
img_sli = Picture.objects.get(name="sli.jpg")
|
||||||
@@ -741,6 +577,129 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
|
||||||
|
beers_type, cotis_type, refill_type, verre_type = (
|
||||||
|
ProductType.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ProductType(name="Bières bouteilles"),
|
||||||
|
ProductType(name="Cotisations"),
|
||||||
|
ProductType(name="Rechargements"),
|
||||||
|
ProductType(name="Verre"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cotis = Product.objects.create(
|
||||||
|
name="Cotis 1 semestre",
|
||||||
|
code="1SCOTIZ",
|
||||||
|
product_type=cotis_type,
|
||||||
|
purchase_price=15,
|
||||||
|
club=clubs.ae,
|
||||||
|
)
|
||||||
|
cotis2 = Product.objects.create(
|
||||||
|
name="Cotis 2 semestres",
|
||||||
|
code="2SCOTIZ",
|
||||||
|
product_type=cotis_type,
|
||||||
|
purchase_price="28",
|
||||||
|
club=clubs.ae,
|
||||||
|
)
|
||||||
|
refill = Product.objects.create(
|
||||||
|
name="Rechargement 15 €",
|
||||||
|
code="15REFILL",
|
||||||
|
product_type=refill_type,
|
||||||
|
purchase_price=15,
|
||||||
|
club=clubs.ae,
|
||||||
|
)
|
||||||
|
barb = Product.objects.create(
|
||||||
|
name="Barbar",
|
||||||
|
code="BARB",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=clubs.pdf,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
cble = Product.objects.create(
|
||||||
|
name="Chimay Bleue",
|
||||||
|
code="CBLE",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=clubs.pdf,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
cons = Product.objects.create(
|
||||||
|
name="Consigne Eco-cup",
|
||||||
|
code="CONS",
|
||||||
|
product_type=verre_type,
|
||||||
|
purchase_price="1",
|
||||||
|
club=clubs.pdf,
|
||||||
|
)
|
||||||
|
dcons = Product.objects.create(
|
||||||
|
name="Déconsigne Eco-cup",
|
||||||
|
code="DECO",
|
||||||
|
product_type=verre_type,
|
||||||
|
purchase_price="-1",
|
||||||
|
club=clubs.pdf,
|
||||||
|
)
|
||||||
|
cors = Product.objects.create(
|
||||||
|
name="Corsendonk",
|
||||||
|
code="CORS",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=clubs.pdf,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
carolus = Product.objects.create(
|
||||||
|
name="Carolus",
|
||||||
|
code="CARO",
|
||||||
|
product_type=beers_type,
|
||||||
|
purchase_price="1.50",
|
||||||
|
club=clubs.pdf,
|
||||||
|
limit_age=18,
|
||||||
|
)
|
||||||
|
Product.objects.create(
|
||||||
|
name="remboursement",
|
||||||
|
code="REMBOURS",
|
||||||
|
purchase_price=0,
|
||||||
|
club=clubs.refound,
|
||||||
|
)
|
||||||
|
ReturnableProduct.objects.create(
|
||||||
|
product=cons, returned_product=dcons, max_return=3
|
||||||
|
)
|
||||||
|
mde = Counter.objects.get(name="MDE")
|
||||||
|
mde.products.add(barb, cble, cons, dcons)
|
||||||
|
eboutic = Counter.objects.get(name="Eboutic")
|
||||||
|
eboutic.products.add(barb, cotis, cotis2, refill)
|
||||||
|
|
||||||
|
cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
|
||||||
|
Price.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Price(product=cotis, amount=15),
|
||||||
|
Price(product=cotis2, amount=28),
|
||||||
|
Price(product=refill, amount=15),
|
||||||
|
Price(product=barb, amount=1.7),
|
||||||
|
Price(product=cble, amount=1.7),
|
||||||
|
Price(product=cors, amount=1.7),
|
||||||
|
Price(product=carolus, amount=1.7),
|
||||||
|
Price(product=cons, amount=1),
|
||||||
|
Price(product=dcons, amount=-1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Price.groups.through.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Price.groups.through(price=cotis, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cotis2, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=refill, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=barb, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cble, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cors, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=carolus, group=groups.subscribers),
|
||||||
|
Price.groups.through(price=cotis, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=cotis2, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=cons, group=groups.old_subscribers),
|
||||||
|
Price.groups.through(price=dcons, group=groups.old_subscribers),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def _create_profile_pict(self, user: User):
|
def _create_profile_pict(self, user: User):
|
||||||
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
|
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
|
||||||
file = resize_image(Image.open(path), 400, "WEBP")
|
file = resize_image(Image.open(path), 400, "WEBP")
|
||||||
@@ -777,6 +736,105 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
s.save()
|
s.save()
|
||||||
|
|
||||||
|
def _create_clubs(self) -> PopulatedClubs:
|
||||||
|
ae = Club.objects.create(
|
||||||
|
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||||
|
)
|
||||||
|
ae.board_group.permissions.add(
|
||||||
|
*Permission.objects.filter(
|
||||||
|
codename__in=[
|
||||||
|
"view_subscription",
|
||||||
|
"add_subscription",
|
||||||
|
"add_membership",
|
||||||
|
"view_hidden_user",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pdf = Club.objects.create(
|
||||||
|
id=settings.SITH_PDF_CLUB_ID,
|
||||||
|
name="PdF",
|
||||||
|
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||||
|
)
|
||||||
|
troll = Club.objects.create(
|
||||||
|
name="Troll Penché", address="Terre Du Milieu", parent=ae
|
||||||
|
)
|
||||||
|
refound = Club.objects.create(
|
||||||
|
name="Carte AE", address="Jamais imprimée", parent=ae
|
||||||
|
)
|
||||||
|
roles = []
|
||||||
|
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
|
||||||
|
board_roles = [
|
||||||
|
"Trésorier⸱e",
|
||||||
|
"Secrétaire",
|
||||||
|
"Respo Info",
|
||||||
|
"Respo Com",
|
||||||
|
"Membre du bureau",
|
||||||
|
]
|
||||||
|
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
|
||||||
|
for club in ae, pdf, troll, refound:
|
||||||
|
for i, role in enumerate(presidency_roles):
|
||||||
|
roles.append(
|
||||||
|
ClubRole(
|
||||||
|
club=club, order=i, name=role, is_presidency=True, is_board=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for i, role in enumerate(board_roles, start=len(presidency_roles)):
|
||||||
|
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
|
||||||
|
for i, role in enumerate(
|
||||||
|
simple_roles, start=len(presidency_roles) + len(board_roles)
|
||||||
|
):
|
||||||
|
roles.append(ClubRole(club=club, order=i, name=role))
|
||||||
|
ClubRole.objects.bulk_create(roles)
|
||||||
|
insta, fb, discord, _ = LinkType.objects.bulk_create(
|
||||||
|
[
|
||||||
|
LinkType(
|
||||||
|
name="instagram",
|
||||||
|
icon="fa-brands fa-square-instagram",
|
||||||
|
url_base="https://www.instagram.com",
|
||||||
|
),
|
||||||
|
LinkType(
|
||||||
|
name="facebook",
|
||||||
|
icon="fa-brands fa-facebook",
|
||||||
|
url_base="https://www.facebook.com",
|
||||||
|
),
|
||||||
|
LinkType(
|
||||||
|
name="discord",
|
||||||
|
icon="fa-brands fa-discord",
|
||||||
|
url_base="https://discord.gg",
|
||||||
|
),
|
||||||
|
LinkType(name="generic", icon="fa fa-link", url_base=""),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ClubLink.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ClubLink(
|
||||||
|
name="insta AE",
|
||||||
|
url="https://www.instagram.com/ae_utbm/",
|
||||||
|
club=ae,
|
||||||
|
link_type=insta,
|
||||||
|
),
|
||||||
|
ClubLink(
|
||||||
|
name="insta activités AE",
|
||||||
|
url="https://www.instagram.com/activites_ae/",
|
||||||
|
club=ae,
|
||||||
|
link_type=insta,
|
||||||
|
),
|
||||||
|
ClubLink(
|
||||||
|
name="facebook AE",
|
||||||
|
url="https://www.facebook.com/ae_utbm",
|
||||||
|
club=ae,
|
||||||
|
link_type=fb,
|
||||||
|
),
|
||||||
|
ClubLink(
|
||||||
|
name="Discord",
|
||||||
|
url="https://discord.gg/QvTm3XJrHR",
|
||||||
|
club=ae,
|
||||||
|
link_type=discord,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
|
||||||
|
|
||||||
def _create_groups(self) -> PopulatedGroups:
|
def _create_groups(self) -> PopulatedGroups:
|
||||||
perms = Permission.objects.all()
|
perms = Permission.objects.all()
|
||||||
|
|
||||||
@@ -789,16 +847,16 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
subscribers = Group.objects.create(name="Cotisants")
|
subscribers = Group.objects.create(name="Cotisants")
|
||||||
subscribers.permissions.add(
|
subscribers.permissions.add(
|
||||||
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
|
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
|
||||||
)
|
)
|
||||||
old_subscribers = Group.objects.create(name="Anciens cotisants")
|
old_subscribers = Group.objects.create(name="Anciens cotisants")
|
||||||
old_subscribers.permissions.add(
|
old_subscribers.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
perms.filter(
|
perms.filter(
|
||||||
codename__in=[
|
codename__in=[
|
||||||
"view_uv",
|
"view_ue",
|
||||||
"view_uvcomment",
|
"view_uecomment",
|
||||||
"add_uvcommentreport",
|
"add_uecommentreport",
|
||||||
"view_user",
|
"view_user",
|
||||||
"view_picture",
|
"view_picture",
|
||||||
"view_album",
|
"view_album",
|
||||||
@@ -874,7 +932,7 @@ class Command(BaseCommand):
|
|||||||
pedagogy_admin.permissions.add(
|
pedagogy_admin.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
perms.filter(content_type__app_label="pedagogy")
|
perms.filter(content_type__app_label="pedagogy")
|
||||||
.exclude(codename__in=["change_uvcomment"])
|
.exclude(codename__in=["change_uecomment"])
|
||||||
.values_list("pk", flat=True)
|
.values_list("pk", flat=True)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -907,3 +965,132 @@ class Command(BaseCommand):
|
|||||||
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
||||||
BanGroup.objects.create(name="Banned from counters", description="")
|
BanGroup.objects.create(name="Banned from counters", description="")
|
||||||
BanGroup.objects.create(name="Banned to subscribe", description="")
|
BanGroup.objects.create(name="Banned to subscribe", description="")
|
||||||
|
|
||||||
|
def _create_elections(
|
||||||
|
self,
|
||||||
|
groups: PopulatedGroups,
|
||||||
|
clubs: PopulatedClubs,
|
||||||
|
skia: User,
|
||||||
|
sli: User,
|
||||||
|
krophil: User,
|
||||||
|
):
|
||||||
|
"""Populate elections.
|
||||||
|
|
||||||
|
4 elections are created :
|
||||||
|
|
||||||
|
- one that has not started yet,
|
||||||
|
- one on the candidature period
|
||||||
|
- one on the vote period
|
||||||
|
- one that is finished
|
||||||
|
|
||||||
|
All elections have two lists, are linked to the AE and Troll clubs,
|
||||||
|
and have one role for each board role of thos two clubs, plus
|
||||||
|
an additional role linked to no club roles.
|
||||||
|
|
||||||
|
The ongoing vote and finished elections have candidates.
|
||||||
|
|
||||||
|
The finished election has 10 voters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def election_factory(title: str, start_candidature: datetime):
|
||||||
|
return Election(
|
||||||
|
title=title,
|
||||||
|
description="",
|
||||||
|
start_candidature=start_candidature,
|
||||||
|
end_candidature=start_candidature + timedelta(days=7),
|
||||||
|
start_date=start_candidature + timedelta(days=7),
|
||||||
|
end_date=start_candidature + timedelta(days=14),
|
||||||
|
)
|
||||||
|
|
||||||
|
# create the elections
|
||||||
|
elections = Election.objects.bulk_create(
|
||||||
|
[
|
||||||
|
election_factory("Election terminée", now() - timedelta(days=14)),
|
||||||
|
election_factory("Votes en cours", now() - timedelta(days=7)),
|
||||||
|
election_factory("Candidatures en cours", now()),
|
||||||
|
election_factory("Election à venir", now() + timedelta(days=7)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
finished, ongoing_vote, _ongoing_candidature, _not_started = elections
|
||||||
|
|
||||||
|
# set the groups (all elections have the same groups)
|
||||||
|
groups.public.viewable_elections.set(elections)
|
||||||
|
clubs.ae.board_group.editable_elections.set(elections)
|
||||||
|
groups.subscribers.candidate_elections.set(elections)
|
||||||
|
groups.subscribers.votable_elections.set(elections)
|
||||||
|
|
||||||
|
# link elections to clubs (AE and Troll for all elections)
|
||||||
|
Election.clubs.through.objects.bulk_create(
|
||||||
|
[
|
||||||
|
*[Election.clubs.through(club=clubs.ae, election=e) for e in elections],
|
||||||
|
*[
|
||||||
|
Election.clubs.through(club=clubs.troll, election=e)
|
||||||
|
for e in elections
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create lists (all elections have two lists)
|
||||||
|
ElectionList.objects.bulk_create(
|
||||||
|
[
|
||||||
|
*[ElectionList(title="Candidat libre", election=e) for e in elections],
|
||||||
|
*[ElectionList(title="Troll", election=e) for e in elections],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create roles.
|
||||||
|
# Elections have a role for each board club role of AE and Troll,
|
||||||
|
# +an additional role linked to no club role
|
||||||
|
club_roles = list(
|
||||||
|
ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True)
|
||||||
|
.select_related("club")
|
||||||
|
.order_by("club_id", "order")
|
||||||
|
)
|
||||||
|
Role.objects.bulk_create(
|
||||||
|
[
|
||||||
|
*[
|
||||||
|
Role(election=e, title=f"{r.name} {r.club.name}", club_role=r)
|
||||||
|
for r in club_roles
|
||||||
|
for e in elections
|
||||||
|
],
|
||||||
|
*[Role(election=e, title="Rôle libre") for e in elections],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# create candidatures for ongoing_vote and finished elections
|
||||||
|
candidatures = []
|
||||||
|
lipsum = "\n\n".join(paragraphs(2))
|
||||||
|
for election in ongoing_vote, finished:
|
||||||
|
lists = list(election.election_lists.order_by("id"))
|
||||||
|
roles = list(election.roles.order_by("order")[:3])
|
||||||
|
candidatures.extend(
|
||||||
|
[
|
||||||
|
Candidature(
|
||||||
|
role=roles[0], user=skia, election_list=lists[0], program=lipsum
|
||||||
|
),
|
||||||
|
Candidature(
|
||||||
|
role=roles[1], user=sli, election_list=lists[0], program=lipsum
|
||||||
|
),
|
||||||
|
Candidature(
|
||||||
|
role=roles[2], user=krophil, election_list=lists[1], program=""
|
||||||
|
),
|
||||||
|
Candidature(
|
||||||
|
role=roles[2], user=sli, election_list=lists[0], program=lipsum
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
candidatures = Candidature.objects.bulk_create(candidatures)
|
||||||
|
|
||||||
|
skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished
|
||||||
|
votes = Vote.objects.bulk_create(
|
||||||
|
[
|
||||||
|
*[Vote(role=skia.role) for _ in range(6)],
|
||||||
|
*[Vote(role=sli_vp.role) for _ in range(8)],
|
||||||
|
*[Vote(role=krophil.role) for _ in range(9)],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
skia.votes.set(votes[:6])
|
||||||
|
sli_vp.votes.set(votes[6:14])
|
||||||
|
krophil.votes.set(votes[14:20])
|
||||||
|
sli_treso.votes.set(votes[20:23])
|
||||||
|
finished.voters.set(list(User.objects.all()[:10]))
|
||||||
|
|||||||
@@ -11,19 +11,20 @@ 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
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, ClubRole, Membership
|
||||||
from core.models import Group, User
|
from core.models import Group, User, UserBan
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
Permanency,
|
Permanency,
|
||||||
|
Price,
|
||||||
Product,
|
Product,
|
||||||
ProductType,
|
ProductType,
|
||||||
Refilling,
|
Refilling,
|
||||||
Selling,
|
Selling,
|
||||||
)
|
)
|
||||||
from forum.models import Forum, ForumMessage, ForumTopic
|
from forum.models import Forum, ForumMessage, ForumTopic
|
||||||
from pedagogy.models import UV
|
from pedagogy.models import UE
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write("Creating users...")
|
self.stdout.write("Creating users...")
|
||||||
users = self.create_users()
|
users = self.create_users()
|
||||||
|
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of 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(subscribers)
|
self.create_subscriptions(subscribers)
|
||||||
@@ -74,7 +76,7 @@ class Command(BaseCommand):
|
|||||||
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
|
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
|
||||||
)
|
)
|
||||||
self.stdout.write("Creating uvs...")
|
self.stdout.write("Creating uvs...")
|
||||||
self.create_uvs()
|
self.create_ues()
|
||||||
self.stdout.write("Creating products...")
|
self.stdout.write("Creating products...")
|
||||||
self.create_products()
|
self.create_products()
|
||||||
self.stdout.write("Creating sales and refills...")
|
self.stdout.write("Creating sales and refills...")
|
||||||
@@ -88,6 +90,8 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write("Done")
|
self.stdout.write("Done")
|
||||||
|
|
||||||
def create_users(self) -> list[User]:
|
def create_users(self) -> list[User]:
|
||||||
|
# Create a single password hash for all users to make it faster.
|
||||||
|
# It's insecure as hell, but it's ok since it's only for dev purposes.
|
||||||
password = make_password("plop")
|
password = make_password("plop")
|
||||||
users = [
|
users = [
|
||||||
User(
|
User(
|
||||||
@@ -114,14 +118,33 @@ class Command(BaseCommand):
|
|||||||
public_group.users.add(*users)
|
public_group.users.add(*users)
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
def create_bans(self, users: list[User]):
|
||||||
|
ban_groups = [
|
||||||
|
settings.SITH_GROUP_BANNED_COUNTER_ID,
|
||||||
|
settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
|
||||||
|
settings.SITH_GROUP_BANNED_ALCOHOL_ID,
|
||||||
|
]
|
||||||
|
UserBan.objects.bulk_create(
|
||||||
|
[
|
||||||
|
UserBan(
|
||||||
|
user=user,
|
||||||
|
ban_group_id=i,
|
||||||
|
reason=self.faker.sentence(),
|
||||||
|
expires_at=make_aware(self.faker.future_datetime("+1y")),
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
for i in random.sample(ban_groups, k=random.randint(1, len(ban_groups)))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
s = Subscription(member=_user, payment_method=payment_method)
|
||||||
sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
|
s.subscription_start = s.compute_start(d=start_date, duration=duration)
|
||||||
sub.subscription_end = sub.compute_end(duration)
|
s.subscription_end = s.compute_end(duration)
|
||||||
return sub
|
return s
|
||||||
|
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
customers = []
|
customers = []
|
||||||
@@ -150,20 +173,25 @@ class Command(BaseCommand):
|
|||||||
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
||||||
|
|
||||||
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
||||||
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
|
roles: list[ClubRole] = list(club.roles.all())
|
||||||
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
|
|
||||||
|
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
|
||||||
|
important_roles = [r for r in roles if r.is_board]
|
||||||
|
important_roles.sort(key=lambda r: r.order)
|
||||||
|
simple_board_role = important_roles.pop()
|
||||||
|
member_roles = [r for r in roles if not r.is_board]
|
||||||
user_idx = 0
|
user_idx = 0
|
||||||
while (role := next(roles)) > 2:
|
for _role in important_roles:
|
||||||
# one member for each major role
|
# one member for each major role
|
||||||
yield users[user_idx], role
|
yield users[user_idx], _role
|
||||||
user_idx += 1
|
user_idx += 1
|
||||||
for _ in range(int(0.3 * (len(users) - user_idx))):
|
for _ in range(int(0.3 * (len(users) - user_idx))):
|
||||||
# 30% of the remaining in the board
|
# 30% of the remaining in the board
|
||||||
yield users[user_idx], 2
|
yield users[user_idx], simple_board_role
|
||||||
user_idx += 1
|
user_idx += 1
|
||||||
for remaining in users[user_idx + 1 :]:
|
for remaining in users[user_idx + 1 :]:
|
||||||
# everything else is a simple member
|
# everything else is a simple member
|
||||||
yield remaining, 1
|
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
|
||||||
|
|
||||||
memberships = []
|
memberships = []
|
||||||
old_members = old_members.copy()
|
old_members = old_members.copy()
|
||||||
@@ -175,24 +203,19 @@ class Command(BaseCommand):
|
|||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=self.faker.past_date(start),
|
end_date=self.faker.past_date(start),
|
||||||
user=old,
|
user=old,
|
||||||
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
|
role=random.choice(roles),
|
||||||
club=club,
|
club=club,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for member, role in zip_roles(members):
|
for member, role in zip_roles(members):
|
||||||
start = self.faker.past_date("-1y")
|
start = self.faker.past_date("-1y")
|
||||||
memberships.append(
|
memberships.append(
|
||||||
Membership(
|
Membership(start_date=start, user=member, role=role, club=club)
|
||||||
start_date=start,
|
|
||||||
user=member,
|
|
||||||
role=role,
|
|
||||||
club=club,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
memberships = Membership.objects.bulk_create(memberships)
|
memberships = Membership.objects.bulk_create(memberships)
|
||||||
Membership._add_club_groups(memberships)
|
Membership._add_club_groups(memberships)
|
||||||
|
|
||||||
def create_uvs(self):
|
def create_ues(self):
|
||||||
root = User.objects.get(username="root")
|
root = User.objects.get(username="root")
|
||||||
categories = ["CS", "TM", "OM", "QC", "EC"]
|
categories = ["CS", "TM", "OM", "QC", "EC"]
|
||||||
branches = ["TC", "GMC", "GI", "EDIM", "E", "IMSI", "HUMA"]
|
branches = ["TC", "GMC", "GI", "EDIM", "E", "IMSI", "HUMA"]
|
||||||
@@ -207,7 +230,7 @@ class Command(BaseCommand):
|
|||||||
+ str(random.randint(10, 90))
|
+ str(random.randint(10, 90))
|
||||||
)
|
)
|
||||||
uvs.append(
|
uvs.append(
|
||||||
UV(
|
UE(
|
||||||
code=code,
|
code=code,
|
||||||
author=root,
|
author=root,
|
||||||
manager=random.choice(teachers),
|
manager=random.choice(teachers),
|
||||||
@@ -229,7 +252,7 @@ class Command(BaseCommand):
|
|||||||
hours_TE=random.randint(15, 40),
|
hours_TE=random.randint(15, 40),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
UV.objects.bulk_create(uvs, ignore_conflicts=True)
|
UE.objects.bulk_create(uvs, ignore_conflicts=True)
|
||||||
|
|
||||||
def create_products(self):
|
def create_products(self):
|
||||||
categories = [
|
categories = [
|
||||||
@@ -256,6 +279,7 @@ class Command(BaseCommand):
|
|||||||
# 2/3 of the products are owned by AE
|
# 2/3 of the products are owned by AE
|
||||||
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
|
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
|
||||||
products = []
|
products = []
|
||||||
|
prices = []
|
||||||
buying_groups = []
|
buying_groups = []
|
||||||
selling_places = []
|
selling_places = []
|
||||||
for _ in range(200):
|
for _ in range(200):
|
||||||
@@ -266,25 +290,28 @@ class Command(BaseCommand):
|
|||||||
product_type=random.choice(categories),
|
product_type=random.choice(categories),
|
||||||
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
|
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
|
||||||
purchase_price=price,
|
purchase_price=price,
|
||||||
selling_price=price,
|
|
||||||
special_selling_price=price - min(0.5, price),
|
|
||||||
club=random.choice(clubs),
|
club=random.choice(clubs),
|
||||||
limit_age=0 if random.random() > 0.2 else 18,
|
limit_age=0 if random.random() > 0.2 else 18,
|
||||||
archived=bool(random.random() > 0.7),
|
archived=self.faker.boolean(60),
|
||||||
)
|
)
|
||||||
products.append(product)
|
products.append(product)
|
||||||
# there will be products without buying groups
|
for i in range(random.randint(0, 3)):
|
||||||
# but there are also such products in the real database
|
product_price = Price(
|
||||||
buying_groups.extend(
|
amount=price, product=product, is_always_shown=self.faker.boolean()
|
||||||
Product.buying_groups.through(product=product, group=group)
|
)
|
||||||
for group in random.sample(groups, k=random.randint(0, 3))
|
# prices for non-subscribers will be higher than for subscribers
|
||||||
)
|
price *= 1.2
|
||||||
|
prices.append(product_price)
|
||||||
|
buying_groups.append(
|
||||||
|
Price.groups.through(price=product_price, group=groups[i])
|
||||||
|
)
|
||||||
selling_places.extend(
|
selling_places.extend(
|
||||||
Counter.products.through(counter=counter, product=product)
|
Counter.products.through(counter=counter, product=product)
|
||||||
for counter in random.sample(counters, random.randint(0, 4))
|
for counter in random.sample(counters, random.randint(0, 4))
|
||||||
)
|
)
|
||||||
Product.objects.bulk_create(products)
|
Product.objects.bulk_create(products)
|
||||||
Product.buying_groups.through.objects.bulk_create(buying_groups)
|
Price.objects.bulk_create(prices)
|
||||||
|
Price.groups.through.objects.bulk_create(buying_groups)
|
||||||
Counter.products.through.objects.bulk_create(selling_places)
|
Counter.products.through.objects.bulk_create(selling_places)
|
||||||
|
|
||||||
def create_sales(self, sellers: list[User]):
|
def create_sales(self, sellers: list[User]):
|
||||||
@@ -298,7 +325,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
products = list(Product.objects.all())
|
prices = list(Price.objects.select_related("product").all())
|
||||||
counters = list(
|
counters = list(
|
||||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
|
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
|
||||||
)
|
)
|
||||||
@@ -308,14 +335,14 @@ class Command(BaseCommand):
|
|||||||
# the longer the customer has existed, the higher the mean of nb_products
|
# the longer the customer has existed, the higher the mean of nb_products
|
||||||
mu = 5 + (now().year - customer.since.year) * 2
|
mu = 5 + (now().year - customer.since.year) * 2
|
||||||
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
|
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
|
||||||
favoured_products = random.sample(products, k=(random.randint(1, 5)))
|
favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
|
||||||
favoured_counter = random.choice(counters)
|
favoured_counter = random.choice(counters)
|
||||||
this_customer_sales = []
|
this_customer_sales = []
|
||||||
for _ in range(nb_sales):
|
for _ in range(nb_sales):
|
||||||
product = (
|
price = (
|
||||||
random.choice(favoured_products)
|
random.choice(favoured_prices)
|
||||||
if random.random() > 0.7
|
if random.random() > 0.7
|
||||||
else random.choice(products)
|
else random.choice(prices)
|
||||||
)
|
)
|
||||||
counter = (
|
counter = (
|
||||||
favoured_counter
|
favoured_counter
|
||||||
@@ -324,11 +351,11 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
this_customer_sales.append(
|
this_customer_sales.append(
|
||||||
Selling(
|
Selling(
|
||||||
product=product,
|
product=price.product,
|
||||||
counter=counter,
|
counter=counter,
|
||||||
club_id=product.club_id,
|
club_id=price.product.club_id,
|
||||||
quantity=random.randint(1, 5),
|
quantity=random.randint(1, 5),
|
||||||
unit_price=product.selling_price,
|
unit_price=price.amount,
|
||||||
seller=random.choice(sellers),
|
seller=random.choice(sellers),
|
||||||
customer=customer,
|
customer=customer,
|
||||||
date=make_aware(
|
date=make_aware(
|
||||||
@@ -350,7 +377,6 @@ class Command(BaseCommand):
|
|||||||
date=make_aware(
|
date=make_aware(
|
||||||
self.faker.date_time_between(customer.since, localdate())
|
self.faker.date_time_between(customer.since, localdate())
|
||||||
),
|
),
|
||||||
is_validated=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sales.extend(this_customer_sales)
|
sales.extend(this_customer_sales)
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2023 © AE UTBM
|
|
||||||
# ae@utbm.fr / ae.info@utbm.fr
|
|
||||||
#
|
|
||||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
|
||||||
# https://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
|
||||||
#
|
|
||||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
|
||||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
|
||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
|
||||||
#
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import mistune
|
|
||||||
from django.urls import reverse
|
|
||||||
from mistune import HTMLRenderer, Markdown
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from mistune import InlineParser, InlineState
|
|
||||||
|
|
||||||
# match __text__, without linebreak in the text, nor backslash prepending an underscore
|
|
||||||
# Examples :
|
|
||||||
# - "__text__" : OK
|
|
||||||
# - "__te xt__" : OK
|
|
||||||
# - "__te_xt__" : nope (underscore in the middle)
|
|
||||||
# - "__te\_xt__" : Ok (the middle underscore is escaped)
|
|
||||||
# - "__te\nxt__" : nope (there is a linebreak in the text)
|
|
||||||
# - "\__text__" : nope (one of the underscores have a backslash prepended)
|
|
||||||
# - "\\__text__" : Ok (the backslash is ignored, because there is another backslash before)
|
|
||||||
UNDERLINED_RE = (
|
|
||||||
r"(?<!\\)(?:\\{2})*" # ignore if there is an odd number of backslashes before
|
|
||||||
r"_{2}" # two underscores
|
|
||||||
r"(?P<underlined>([^\\_]|\\.)+)" # the actual text
|
|
||||||
r"_{2}" # closing underscores
|
|
||||||
)
|
|
||||||
|
|
||||||
SITH_LINK_RE = (
|
|
||||||
r"\[(?P<page_name>[\w\s]+)\]" # [nom du lien]
|
|
||||||
r"\(page:\/\/" # (page://
|
|
||||||
r"(?P<page_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
|
|
||||||
r"\)" # )
|
|
||||||
)
|
|
||||||
|
|
||||||
CUSTOM_DIMENSIONS_IMAGE_RE = (
|
|
||||||
r"\[(?P<img_name>[\w\s]+)\]" # [nom du lien]
|
|
||||||
r"\(img:\/\/" # (img://
|
|
||||||
r"(?P<img_slug>[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9])" # actual page name
|
|
||||||
r"\)" # )
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_underline(_inline: InlineParser, m: re.Match, state: InlineState):
|
|
||||||
state.append_token({"type": "underline", "raw": m.group("underlined")})
|
|
||||||
return m.end()
|
|
||||||
|
|
||||||
|
|
||||||
def underline(md_instance: Markdown):
|
|
||||||
md_instance.inline.register(
|
|
||||||
"underline",
|
|
||||||
UNDERLINED_RE,
|
|
||||||
parse_underline,
|
|
||||||
before="emphasis",
|
|
||||||
)
|
|
||||||
md_instance.renderer.register("underline", lambda _, text: f"<u>{text}</u>")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_sith_link(_inline: InlineParser, m: re.Match, state: InlineState):
|
|
||||||
page_name = m.group("page_name")
|
|
||||||
page_slug = m.group("page_slug")
|
|
||||||
state.append_token(
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"children": [{"type": "text", "raw": page_name}],
|
|
||||||
"attrs": {"url": reverse("core:page", kwargs={"page_name": page_slug})},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return m.end()
|
|
||||||
|
|
||||||
|
|
||||||
def sith_link(md_instance: Markdown):
|
|
||||||
md_instance.inline.register(
|
|
||||||
"sith_link",
|
|
||||||
SITH_LINK_RE,
|
|
||||||
parse_sith_link,
|
|
||||||
before="emphasis",
|
|
||||||
)
|
|
||||||
# no custom renderer here.
|
|
||||||
# we just add another parsing rule, but render it as if it was
|
|
||||||
# a regular markdown link
|
|
||||||
|
|
||||||
|
|
||||||
class SithRenderer(HTMLRenderer):
|
|
||||||
def image(self, text: str, url: str, title=None) -> str:
|
|
||||||
if "?" not in url:
|
|
||||||
return super().image(text, url, title)
|
|
||||||
|
|
||||||
new_url, params = url.rsplit("?", maxsplit=1)
|
|
||||||
m = re.match(r"^(?P<width>\d+(%|px)?)(x(?P<height>\d+(%|px)?))?$", params)
|
|
||||||
if not m:
|
|
||||||
return super().image(text, url, title)
|
|
||||||
|
|
||||||
width, height = m.group("width"), m.group("height")
|
|
||||||
if not width.endswith(("%", "px")):
|
|
||||||
width += "px"
|
|
||||||
style = f"width:{width};"
|
|
||||||
if height is not None:
|
|
||||||
if not height.endswith(("%", "px")):
|
|
||||||
height += "px"
|
|
||||||
style += f"height:{height};"
|
|
||||||
return super().image(text, new_url, title).replace("/>", f'style="{style}" />')
|
|
||||||
|
|
||||||
|
|
||||||
markdown = mistune.create_markdown(
|
|
||||||
renderer=SithRenderer(escape=True),
|
|
||||||
plugins=[
|
|
||||||
underline,
|
|
||||||
sith_link,
|
|
||||||
"strikethrough",
|
|
||||||
"footnotes",
|
|
||||||
"table",
|
|
||||||
"spoiler",
|
|
||||||
"subscript",
|
|
||||||
"superscript",
|
|
||||||
"url",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-09 15:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("core", "0047_alter_notification_date_alter_notification_type")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="user",
|
||||||
|
options={
|
||||||
|
"permissions": [("view_hidden_user", "Can view hidden users")],
|
||||||
|
"verbose_name": "user",
|
||||||
|
"verbose_name_plural": "users",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="user", old_name="is_subscriber_viewable", new_name="is_viewable"
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="is_viewable",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name="Profile visible by subscribers",
|
||||||
|
help_text=(
|
||||||
|
"If you disable this option, only admin users "
|
||||||
|
"will be able to see your profile."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-03-14 08:39
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("core", "0048_alter_user_options")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="whitelisted_users",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text=(
|
||||||
|
"Even if this profile is hidden, "
|
||||||
|
"the users in this list will still be able to see it."
|
||||||
|
),
|
||||||
|
related_name="visible_by_whitelist",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="whitelisted users",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="preferences",
|
||||||
|
name="show_my_stats",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=(
|
||||||
|
"Allow subscribers (or whitelisted users "
|
||||||
|
"if your profile is hidden) to access your AE account stats."
|
||||||
|
),
|
||||||
|
verbose_name="show your stats to others",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-05-01 08:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import F
|
||||||
|
|
||||||
|
|
||||||
|
def set_updated_at(apps: StateApps, schema_editor):
|
||||||
|
SithFile = apps.get_model("core", "SithFile")
|
||||||
|
SithFile.objects.update(updated_at=F("date"))
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("core", "0049_user_whitelisted_users")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sithfile",
|
||||||
|
name="moderator",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="moderated_files",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sithfile",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="owned_files",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sithfile",
|
||||||
|
name="updated_at",
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||||
|
),
|
||||||
|
migrations.RunPython(set_updated_at, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
+134
-48
@@ -23,12 +23,13 @@
|
|||||||
#
|
#
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
import string
|
import string
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Self
|
from typing import TYPE_CHECKING, Final, Self
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -37,7 +38,6 @@ from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
|
|||||||
from django.contrib.auth.models import Group as AuthGroup
|
from django.contrib.auth.models import Group as AuthGroup
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
from django.core import validators
|
from django.core import validators
|
||||||
from django.core.cache import cache
|
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
@@ -54,6 +54,8 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from phonenumber_field.modelfields import PhoneNumberField
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
from core.utils import get_last_promo
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from pydantic import NonNegativeInt
|
from pydantic import NonNegativeInt
|
||||||
@@ -74,24 +76,13 @@ class Group(AuthGroup):
|
|||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
return reverse("core:group_list")
|
return reverse("core:group_list")
|
||||||
|
|
||||||
def save(self, *args, **kwargs) -> None:
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
cache.set(f"sith_group_{self.id}", self)
|
|
||||||
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs) -> None:
|
|
||||||
super().delete(*args, **kwargs)
|
|
||||||
cache.delete(f"sith_group_{self.id}")
|
|
||||||
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_promo(value: int) -> None:
|
def validate_promo(value: int) -> None:
|
||||||
start_year = settings.SITH_SCHOOL_START_YEAR
|
last_promo = get_last_promo()
|
||||||
delta = (localdate() + timedelta(days=180)).year - start_year
|
if not 0 < value <= last_promo:
|
||||||
if value < 0 or delta < value:
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("%(value)s is not a valid promo (between 0 and %(end)s)"),
|
_("%(value)s is not a valid promo (between 0 and %(end)s)"),
|
||||||
params={"value": value, "end": delta},
|
params={"value": value, "end": last_promo},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +127,17 @@ class UserQuerySet(models.QuerySet):
|
|||||||
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
|
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def viewable_by(self, user: User) -> Self:
|
||||||
|
if user.has_perm("core.view_hidden_user"):
|
||||||
|
return self
|
||||||
|
if user.has_perm("core.view_user"):
|
||||||
|
return self.filter(
|
||||||
|
Q(is_viewable=True) | Q(whitelisted_users=user) | Q(pk=user.pk)
|
||||||
|
)
|
||||||
|
if user.is_anonymous:
|
||||||
|
return self.none()
|
||||||
|
return self.filter(id=user.id)
|
||||||
|
|
||||||
|
|
||||||
class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
|
class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
|
||||||
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
|
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
|
||||||
@@ -271,13 +273,34 @@ class User(AbstractUser):
|
|||||||
parent_address = models.CharField(
|
parent_address = models.CharField(
|
||||||
_("parent address"), max_length=128, blank=True, default=""
|
_("parent address"), max_length=128, blank=True, default=""
|
||||||
)
|
)
|
||||||
is_subscriber_viewable = models.BooleanField(
|
is_viewable = models.BooleanField(
|
||||||
_("is subscriber viewable"), default=True
|
_("Profile visible by subscribers"),
|
||||||
|
help_text=_(
|
||||||
|
"If you disable this option, only admin users "
|
||||||
|
"will be able to see your profile."
|
||||||
|
),
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
whitelisted_users = models.ManyToManyField(
|
||||||
|
"User",
|
||||||
|
related_name="visible_by_whitelist",
|
||||||
|
verbose_name=_("whitelisted users"),
|
||||||
|
help_text=_(
|
||||||
|
"Even if this profile is hidden, "
|
||||||
|
"the users in this list will still be able to see it."
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
|
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
|
||||||
|
|
||||||
objects = CustomUserManager()
|
objects = CustomUserManager()
|
||||||
|
|
||||||
|
class Meta(AbstractUser.Meta):
|
||||||
|
abstract = False
|
||||||
|
permissions = [
|
||||||
|
("view_hidden_user", "Can view hidden users"),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.get_display_name()
|
return self.get_display_name()
|
||||||
|
|
||||||
@@ -345,23 +368,27 @@ class User(AbstractUser):
|
|||||||
)
|
)
|
||||||
if group_id is None:
|
if group_id is None:
|
||||||
return False
|
return False
|
||||||
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
|
return group_id in self.all_groups
|
||||||
return self.is_subscribed
|
|
||||||
if group_id == settings.SITH_GROUP_ROOT_ID:
|
|
||||||
return self.is_root
|
|
||||||
return any(g.id == group_id for g in self.cached_groups)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def cached_groups(self) -> list[Group]:
|
def all_groups(self) -> dict[int, Group]:
|
||||||
"""Get the list of groups this user is in."""
|
"""Get the list of groups this user is in."""
|
||||||
return list(self.groups.all())
|
additional_groups = []
|
||||||
|
if self.is_subscribed:
|
||||||
|
additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
|
||||||
|
if self.is_superuser:
|
||||||
|
additional_groups.append(settings.SITH_GROUP_ROOT_ID)
|
||||||
|
qs = self.groups.all()
|
||||||
|
if additional_groups:
|
||||||
|
# This is somewhat counter-intuitive, but this query runs way faster with
|
||||||
|
# a UNION rather than a OR (in average, 0.25ms vs 14ms).
|
||||||
|
# For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
|
||||||
|
qs = qs.union(Group.objects.filter(id__in=additional_groups))
|
||||||
|
return {g.id: g for g in qs}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_root(self) -> bool:
|
def is_root(self) -> bool:
|
||||||
if self.is_superuser:
|
return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
|
||||||
return True
|
|
||||||
root_id = settings.SITH_GROUP_ROOT_ID
|
|
||||||
return any(g.id == root_id for g in self.cached_groups)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_board_member(self) -> bool:
|
def is_board_member(self) -> bool:
|
||||||
@@ -503,7 +530,7 @@ class User(AbstractUser):
|
|||||||
self.username = user_name
|
self.username = user_name
|
||||||
return user_name
|
return user_name
|
||||||
|
|
||||||
def is_owner(self, obj):
|
def is_owner(self, obj: models.Model):
|
||||||
"""Determine if the object is owned by the user."""
|
"""Determine if the object is owned by the user."""
|
||||||
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
|
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
|
||||||
return True
|
return True
|
||||||
@@ -511,7 +538,7 @@ class User(AbstractUser):
|
|||||||
return True
|
return True
|
||||||
return self.is_root
|
return self.is_root
|
||||||
|
|
||||||
def can_edit(self, obj):
|
def can_edit(self, obj: models.Model):
|
||||||
"""Determine if the object can be edited by the user."""
|
"""Determine if the object can be edited by the user."""
|
||||||
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
|
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
|
||||||
return True
|
return True
|
||||||
@@ -525,11 +552,9 @@ class User(AbstractUser):
|
|||||||
pks = list(obj.edit_groups.values_list("id", flat=True))
|
pks = list(obj.edit_groups.values_list("id", flat=True))
|
||||||
if any(self.is_in_group(pk=pk) for pk in pks):
|
if any(self.is_in_group(pk=pk) for pk in pks):
|
||||||
return True
|
return True
|
||||||
if isinstance(obj, User) and obj == self:
|
|
||||||
return True
|
|
||||||
return self.is_owner(obj)
|
return self.is_owner(obj)
|
||||||
|
|
||||||
def can_view(self, obj):
|
def can_view(self, obj: models.Model):
|
||||||
"""Determine if the object can be viewed by the user."""
|
"""Determine if the object can be viewed by the user."""
|
||||||
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
|
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
|
||||||
return True
|
return True
|
||||||
@@ -548,11 +573,36 @@ class User(AbstractUser):
|
|||||||
return True
|
return True
|
||||||
return self.can_edit(obj)
|
return self.can_edit(obj)
|
||||||
|
|
||||||
def can_be_edited_by(self, user):
|
def can_be_edited_by(self, user: User):
|
||||||
return user.is_root or user.is_board_member
|
return user == self or user.is_root or user.is_board_member
|
||||||
|
|
||||||
def can_be_viewed_by(self, user):
|
def can_be_viewed_by(self, user: User) -> bool:
|
||||||
return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root
|
"""Check if the given user can be viewed by this user.
|
||||||
|
|
||||||
|
Given users A and B. A can be viewed by B if :
|
||||||
|
|
||||||
|
- A and B are the same user
|
||||||
|
- or B has the permission to view hidden users
|
||||||
|
- or B can view users in general and A didn't hide its profile
|
||||||
|
- or B is in A's whitelist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def is_in_whitelist(u: User):
|
||||||
|
if (
|
||||||
|
hasattr(self, "_prefetched_objects_cache")
|
||||||
|
and "whitelisted_users" in self._prefetched_objects_cache
|
||||||
|
):
|
||||||
|
return u in self.whitelisted_users.all()
|
||||||
|
return self.whitelisted_users.contains(u)
|
||||||
|
|
||||||
|
return (
|
||||||
|
user.id == self.id
|
||||||
|
or user.has_perm("core.view_hidden_user")
|
||||||
|
or (
|
||||||
|
user.has_perm("core.view_user")
|
||||||
|
and (self.is_viewable or is_in_whitelist(user))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def get_mini_item(self):
|
def get_mini_item(self):
|
||||||
return """
|
return """
|
||||||
@@ -731,7 +781,14 @@ class Preferences(models.Model):
|
|||||||
User, related_name="_preferences", on_delete=models.CASCADE
|
User, related_name="_preferences", on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
receive_weekmail = models.BooleanField(_("receive the Weekmail"), default=False)
|
receive_weekmail = models.BooleanField(_("receive the Weekmail"), default=False)
|
||||||
show_my_stats = models.BooleanField(_("show your stats to others"), default=False)
|
show_my_stats = models.BooleanField(
|
||||||
|
_("show your stats to others"),
|
||||||
|
help_text=_(
|
||||||
|
"Allow subscribers (or whitelisted users "
|
||||||
|
"if your profile is hidden) to access your AE account stats."
|
||||||
|
),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
notify_on_click = models.BooleanField(
|
notify_on_click = models.BooleanField(
|
||||||
_("get a notification for every click"), default=False
|
_("get a notification for every click"), default=False
|
||||||
)
|
)
|
||||||
@@ -796,7 +853,7 @@ class SithFile(models.Model):
|
|||||||
User,
|
User,
|
||||||
related_name="owned_files",
|
related_name="owned_files",
|
||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
edit_groups = models.ManyToManyField(
|
edit_groups = models.ManyToManyField(
|
||||||
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
|
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
|
||||||
@@ -808,6 +865,7 @@ class SithFile(models.Model):
|
|||||||
mime_type = models.CharField(_("mime type"), max_length=30)
|
mime_type = models.CharField(_("mime type"), max_length=30)
|
||||||
size = models.IntegerField(_("size"), default=0)
|
size = models.IntegerField(_("size"), default=0)
|
||||||
date = models.DateTimeField(_("date"), default=timezone.now)
|
date = models.DateTimeField(_("date"), default=timezone.now)
|
||||||
|
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
||||||
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
||||||
moderator = models.ForeignKey(
|
moderator = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
@@ -815,7 +873,7 @@ class SithFile(models.Model):
|
|||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
||||||
is_in_sas = models.BooleanField(
|
is_in_sas = models.BooleanField(
|
||||||
@@ -829,8 +887,10 @@ class SithFile(models.Model):
|
|||||||
return self.get_parent_path() + "/" + self.name
|
return self.get_parent_path() + "/" + self.name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
|
sas_id = settings.SITH_SAS_ROOT_DIR_ID
|
||||||
self.is_in_sas = sas in self.get_parent_list() or self == sas
|
self.is_in_sas = self.id == sas_id or any(
|
||||||
|
p.id == sas_id for p in self.get_parent_list()
|
||||||
|
)
|
||||||
adding = self._state.adding
|
adding = self._state.adding
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if adding:
|
if adding:
|
||||||
@@ -1084,10 +1144,7 @@ class PageQuerySet(models.QuerySet):
|
|||||||
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
|
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
|
||||||
if user.has_perm("core.view_page"):
|
if user.has_perm("core.view_page"):
|
||||||
return self.all()
|
return self.all()
|
||||||
groups_ids = [g.id for g in user.cached_groups]
|
return self.filter(view_groups__in=user.all_groups)
|
||||||
if user.is_subscribed:
|
|
||||||
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
|
|
||||||
return self.filter(view_groups__in=groups_ids)
|
|
||||||
|
|
||||||
|
|
||||||
# This function prevents generating migration upon settings change
|
# This function prevents generating migration upon settings change
|
||||||
@@ -1319,6 +1376,9 @@ class PageRev(models.Model):
|
|||||||
The content is in PageRev.title and PageRev.content .
|
The content is in PageRev.title and PageRev.content .
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
MERGE_TIME_THRESHOLD: Final[timedelta] = timedelta(minutes=20)
|
||||||
|
MERGE_DIFF_THRESHOLD: Final[float] = 0.2
|
||||||
|
|
||||||
revision = models.IntegerField(_("revision"))
|
revision = models.IntegerField(_("revision"))
|
||||||
title = models.CharField(_("page title"), max_length=255, blank=True)
|
title = models.CharField(_("page title"), max_length=255, blank=True)
|
||||||
content = models.TextField(_("page content"), blank=True)
|
content = models.TextField(_("page content"), blank=True)
|
||||||
@@ -1358,7 +1418,33 @@ class PageRev(models.Model):
|
|||||||
return self.page.can_be_edited_by(user)
|
return self.page.can_be_edited_by(user)
|
||||||
|
|
||||||
def is_owned_by(self, user: User) -> bool:
|
def is_owned_by(self, user: User) -> bool:
|
||||||
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
|
return self.page.owner_group_id in user.all_groups
|
||||||
|
|
||||||
|
def similarity_ratio(self, text: str) -> float:
|
||||||
|
"""Similarity ratio between this revision's content and the given text.
|
||||||
|
|
||||||
|
The result is a float in [0; 1], 0 meaning the contents are entirely different,
|
||||||
|
and 1 they are strictly the same.
|
||||||
|
"""
|
||||||
|
# cf. https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
|
||||||
|
return difflib.SequenceMatcher(None, self.content, text).quick_ratio()
|
||||||
|
|
||||||
|
def should_merge(self, other: Self) -> bool:
|
||||||
|
"""Return True if `other` should be merged into `self`, else False.
|
||||||
|
|
||||||
|
It's considered the other revision should be merged into this one if :
|
||||||
|
|
||||||
|
- it was made less than 20 minutes after
|
||||||
|
- by the same author
|
||||||
|
- with a similarity ratio higher than 80%
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
not self._state.adding # cannot merge if the original rev doesn't exist
|
||||||
|
and self.author == other.author
|
||||||
|
and (other.date - self.date) < self.MERGE_TIME_THRESHOLD
|
||||||
|
and (not other._state.adding or other.revision == self.revision + 1)
|
||||||
|
and self.similarity_ratio(other.content) >= (1 - other.MERGE_DIFF_THRESHOLD)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_notification_types():
|
def get_notification_types():
|
||||||
|
|||||||
+28
-4
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
@@ -8,12 +9,24 @@ from django.urls import reverse
|
|||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from haystack.query import SearchQuerySet
|
from haystack.query import SearchQuerySet
|
||||||
from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
|
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile
|
||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field, field_validator
|
||||||
from pydantic_core.core_schema import ValidationInfo
|
from pydantic_core.core_schema import ValidationInfo
|
||||||
|
|
||||||
from core.models import Group, QuickUploadImage, SithFile, User
|
from core.models import Group, QuickUploadImage, SithFile, User
|
||||||
from core.utils import is_image
|
from core.utils import get_last_promo, is_image
|
||||||
|
|
||||||
|
NonEmptyStr = Annotated[str, MinLen(1)]
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationErrorSchema(Schema):
|
||||||
|
class ValidationErrorItem(Schema):
|
||||||
|
loc: list[str | int]
|
||||||
|
msg: str
|
||||||
|
type: str
|
||||||
|
ctx: dict[str, str]
|
||||||
|
|
||||||
|
detail: list[ValidationErrorItem]
|
||||||
|
|
||||||
|
|
||||||
class UploadedImage(UploadedFile):
|
class UploadedImage(UploadedFile):
|
||||||
@@ -107,7 +120,11 @@ class GroupSchema(ModelSchema):
|
|||||||
|
|
||||||
|
|
||||||
class UserFilterSchema(FilterSchema):
|
class UserFilterSchema(FilterSchema):
|
||||||
search: Annotated[str, MinLen(1)]
|
search: Annotated[str, MinLen(1)] | None = None
|
||||||
|
role: Annotated[str, FilterLookup("role__icontains")] | None = None
|
||||||
|
department: str | None = None
|
||||||
|
promo: int | None = None
|
||||||
|
date_of_birth: datetime | None = None
|
||||||
exclude: list[int] | None = Field(
|
exclude: list[int] | None = Field(
|
||||||
None, validation_alias=AliasChoices("exclude", "exclude[]")
|
None, validation_alias=AliasChoices("exclude", "exclude[]")
|
||||||
)
|
)
|
||||||
@@ -136,6 +153,13 @@ class UserFilterSchema(FilterSchema):
|
|||||||
return Q()
|
return Q()
|
||||||
return ~Q(id__in=value)
|
return ~Q(id__in=value)
|
||||||
|
|
||||||
|
@field_validator("promo", mode="after")
|
||||||
|
@classmethod
|
||||||
|
def validate_promo(cls, value: int) -> int:
|
||||||
|
if not 0 < value <= get_last_promo():
|
||||||
|
raise ValueError(f"{value} is not a valid promo")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class MarkdownSchema(Schema):
|
class MarkdownSchema(Schema):
|
||||||
text: str
|
text: str
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { limitedChoices } from "#core:alpine/limited-choices";
|
|
||||||
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications";
|
|
||||||
import sort from "@alpinejs/sort";
|
|
||||||
import Alpine from "alpinejs";
|
|
||||||
|
|
||||||
Alpine.plugin([sort, limitedChoices]);
|
|
||||||
Alpine.magic("notifications", notificationPlugin);
|
|
||||||
window.Alpine = Alpine;
|
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
Alpine.start();
|
|
||||||
});
|
|
||||||
@@ -25,7 +25,7 @@ export function limitedChoices(Alpine: AlpineType) {
|
|||||||
Alpine.directive(
|
Alpine.directive(
|
||||||
"limited-choices",
|
"limited-choices",
|
||||||
(el, { expression }, { evaluateLater, effect }) => {
|
(el, { expression }, { evaluateLater, effect }) => {
|
||||||
const getMaxChoices = evaluateLater(expression);
|
const getMaxChoices = evaluateLater<string>(expression);
|
||||||
let maxChoices: number;
|
let maxChoices: number;
|
||||||
const inputs: HTMLInputElement[] = Array.from(
|
const inputs: HTMLInputElement[] = Array.from(
|
||||||
el.querySelectorAll("input[type='checkbox']"),
|
el.querySelectorAll("input[type='checkbox']"),
|
||||||
@@ -54,9 +54,9 @@ export function limitedChoices(Alpine: AlpineType) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
effect(() => {
|
effect(() => {
|
||||||
getMaxChoices((value: string) => {
|
getMaxChoices((value) => {
|
||||||
const previousValue = maxChoices;
|
const previousValue = maxChoices;
|
||||||
maxChoices = Number.parseInt(value);
|
maxChoices = Number.parseInt(value, 10);
|
||||||
if (maxChoices < previousValue) {
|
if (maxChoices < previousValue) {
|
||||||
// The maximum number of selectable items has been lowered.
|
// The maximum number of selectable items has been lowered.
|
||||||
// Some currently selected elements may need to be removed
|
// Some currently selected elements may need to be removed
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* File containing main functions and library re-exports
|
||||||
|
* that should be accessible throughout the whole website.
|
||||||
|
*
|
||||||
|
* The idea is to group all that shared code into a single bundle,
|
||||||
|
* for more efficient tree-shaking and gzip compression.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sort from "@alpinejs/sort";
|
||||||
|
import Alpine from "alpinejs";
|
||||||
|
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
|
||||||
|
import htmx from "htmx.org";
|
||||||
|
import { limitedChoices } from "#core:alpine/limited-choices";
|
||||||
|
import { expireOldStorage } from "#core:core/localstorage";
|
||||||
|
import { default as navbar } from "#core:core/navbar";
|
||||||
|
import {
|
||||||
|
type NotificationPlugin,
|
||||||
|
notificationsPlugin as notifications,
|
||||||
|
} from "#core:utils/notifications";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alpine
|
||||||
|
*/
|
||||||
|
declare module "alpinejs" {
|
||||||
|
interface Magics<T> {
|
||||||
|
$notifications: NotificationPlugin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Alpine.plugin([sort, limitedChoices, notifications]);
|
||||||
|
// biome-ignore lint/style/useNamingConvention: it's how it's named
|
||||||
|
Object.assign(window, { Alpine });
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
Alpine.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polyfill for country flags (used for language choice)
|
||||||
|
*/
|
||||||
|
polyfillCountryFlagEmojis();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTMX
|
||||||
|
*/
|
||||||
|
document.body.addEventListener(
|
||||||
|
"htmx:beforeRequest" as keyof HTMLElementEventMap,
|
||||||
|
(event) => {
|
||||||
|
(event as CustomEvent).detail.target.ariaBusy = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
document.body.addEventListener(
|
||||||
|
"htmx:beforeSwap" as keyof HTMLElementEventMap,
|
||||||
|
(event) => {
|
||||||
|
(event as CustomEvent).detail.target.ariaBusy = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(window, { htmx });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* navbar
|
||||||
|
*/
|
||||||
|
navbar();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script that clears the cache when the cache version changes
|
||||||
|
*/
|
||||||
|
expireOldStorage();
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { inheritHtmlElement } from "#core:utils/web-components";
|
|
||||||
import TomSelect from "tom-select";
|
import TomSelect from "tom-select";
|
||||||
import type {
|
import type {
|
||||||
RecursivePartial,
|
RecursivePartial,
|
||||||
TomLoadCallback,
|
TomLoadCallback,
|
||||||
TomOption,
|
TomOption,
|
||||||
TomSettings,
|
TomSettings,
|
||||||
} from "tom-select/dist/types/types";
|
} from "tom-select/src/types";
|
||||||
import type { escape_html } from "tom-select/dist/types/utils";
|
import type { escape_html } from "tom-select/src/utils";
|
||||||
|
import { inheritHtmlElement } from "#core:utils/web-components";
|
||||||
|
|
||||||
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
||||||
static observedAttributes = [
|
static observedAttributes = [
|
||||||
@@ -15,7 +15,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
"max",
|
"max",
|
||||||
"min-characters-for-search",
|
"min-characters-for-search",
|
||||||
];
|
];
|
||||||
public widget: TomSelect;
|
public widget!: TomSelect;
|
||||||
|
|
||||||
protected minCharNumberForSearch = 0;
|
protected minCharNumberForSearch = 0;
|
||||||
protected delay: number | null = null;
|
protected delay: number | null = null;
|
||||||
@@ -24,12 +24,12 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
|
|
||||||
protected attributeChangedCallback(
|
protected attributeChangedCallback(
|
||||||
name: string,
|
name: string,
|
||||||
_oldValue?: string,
|
_oldValue: string,
|
||||||
newValue?: string,
|
newValue: string,
|
||||||
) {
|
) {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "delay": {
|
case "delay": {
|
||||||
this.delay = Number.parseInt(newValue) ?? null;
|
this.delay = Number.parseInt(newValue, 10) ?? null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "placeholder": {
|
case "placeholder": {
|
||||||
@@ -37,11 +37,11 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "max": {
|
case "max": {
|
||||||
this.max = Number.parseInt(newValue) ?? null;
|
this.max = Number.parseInt(newValue, 10) ?? null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "min-characters-for-search": {
|
case "min-characters-for-search": {
|
||||||
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
|
this.minCharNumberForSearch = Number.parseInt(newValue, 10) ?? 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@@ -73,7 +73,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
persist: false,
|
persist: false,
|
||||||
maxItems: this.node.multiple ? this.max : 1,
|
maxItems: this.node.multiple ? this.max : 1,
|
||||||
closeAfterSelect: true,
|
closeAfterSelect: true,
|
||||||
loadThrottle: this.delay,
|
loadThrottle: this.delay ?? undefined,
|
||||||
placeholder: this.placeholder,
|
placeholder: this.placeholder,
|
||||||
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
|
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
|
||||||
render: {
|
render: {
|
||||||
@@ -103,7 +103,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
||||||
protected filter?: (items: TomOption[]) => TomOption[] = null;
|
protected filter?: (items: TomOption[]) => TomOption[];
|
||||||
protected minCharNumberForSearch = 2;
|
protected minCharNumberForSearch = 2;
|
||||||
/**
|
/**
|
||||||
* A cache of researches that have been made using this input.
|
* A cache of researches that have been made using this input.
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
|
// @ts-expect-error TS2882
|
||||||
import "tom-select/dist/css/tom-select.default.css";
|
import "tom-select/dist/css/tom-select.default.css";
|
||||||
import { registerComponent } from "#core:utils/web-components";
|
import type { TomOption } from "tom-select/src/types";
|
||||||
import type { TomOption } from "tom-select/dist/types/types";
|
import type { escape_html } from "tom-select/src/utils";
|
||||||
import type { escape_html } from "tom-select/dist/types/utils";
|
|
||||||
import {
|
|
||||||
type GroupSchema,
|
|
||||||
type SithFileSchema,
|
|
||||||
type UserProfileSchema,
|
|
||||||
groupSearchGroup,
|
|
||||||
sithfileSearchFiles,
|
|
||||||
userSearchUsers,
|
|
||||||
} from "#openapi";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AjaxSelect,
|
AjaxSelect,
|
||||||
AutoCompleteSelectBase,
|
AutoCompleteSelectBase,
|
||||||
} from "#core:core/components/ajax-select-base";
|
} from "#core:core/components/ajax-select-base";
|
||||||
|
import { registerComponent } from "#core:utils/web-components";
|
||||||
|
import {
|
||||||
|
type GroupSchema,
|
||||||
|
groupSearchGroup,
|
||||||
|
type SithFileSchema,
|
||||||
|
sithfileSearchFiles,
|
||||||
|
type UserProfileSchema,
|
||||||
|
userSearchUsers,
|
||||||
|
} from "#openapi";
|
||||||
|
|
||||||
@registerComponent("autocomplete-select")
|
@registerComponent("autocomplete-select")
|
||||||
export class AutoCompleteSelect extends AutoCompleteSelectBase {}
|
export class AutoCompleteSelect extends AutoCompleteSelectBase {}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
// @ts-expect-error 2307
|
||||||
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
|
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
|
||||||
import "codemirror/lib/codemirror.css";
|
import "codemirror/lib/codemirror.css";
|
||||||
|
// @ts-expect-error 2307
|
||||||
import "easymde/src/css/easymde.css";
|
import "easymde/src/css/easymde.css";
|
||||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
|
||||||
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
|
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
|
||||||
import type CodeMirror from "codemirror";
|
import type CodeMirror from "codemirror";
|
||||||
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
|
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
|
||||||
import EasyMDE from "easymde";
|
import EasyMDE from "easymde";
|
||||||
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||||
import {
|
import {
|
||||||
type UploadUploadImageErrors,
|
|
||||||
markdownRenderMarkdown,
|
markdownRenderMarkdown,
|
||||||
|
type UploadUploadImageErrors,
|
||||||
uploadUploadImage,
|
uploadUploadImage,
|
||||||
} from "#openapi";
|
} from "#openapi";
|
||||||
|
|
||||||
@@ -25,11 +27,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
file: file,
|
file: file,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.response.ok) {
|
if (response.response !== undefined && !response.response.ok) {
|
||||||
if (response.response.status === 422) {
|
if (response?.response.status === 422) {
|
||||||
onError(
|
onError(
|
||||||
(response.error as UploadUploadImageErrors[422]).detail
|
(response.error as UploadUploadImageErrors[422]).detail
|
||||||
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
|
.map((err) => err.ctx.error)
|
||||||
.join(" ; "),
|
.join(" ; "),
|
||||||
);
|
);
|
||||||
} else if (response.response.status === 403) {
|
} else if (response.response.status === 403) {
|
||||||
@@ -39,6 +41,10 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (response.data === undefined) {
|
||||||
|
// this can't happen, it's just for the type checker to know
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSuccess(response.data.href);
|
onSuccess(response.data.href);
|
||||||
// Workaround function to add an image name to uploaded image
|
// Workaround function to add an image name to uploaded image
|
||||||
// Without this, you get  instead of 
|
// Without this, you get  instead of 
|
||||||
@@ -58,21 +64,18 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
});
|
});
|
||||||
easymde.codemirror.replaceSelection("\n");
|
easymde.codemirror.replaceSelection("\n");
|
||||||
},
|
},
|
||||||
previewRender: (plainText: string, preview: MarkdownInput) => {
|
previewRender: (plainText, preview) => {
|
||||||
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
|
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
|
||||||
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
return Alpine.debounce(() => {
|
||||||
const func = async (
|
const func = async () => {
|
||||||
plainText: string,
|
|
||||||
preview: MarkdownInput,
|
|
||||||
): Promise<null> => {
|
|
||||||
preview.innerHTML = (
|
preview.innerHTML = (
|
||||||
await markdownRenderMarkdown({ body: { text: plainText } })
|
await markdownRenderMarkdown({ body: { text: plainText } })
|
||||||
).data as string;
|
).data as string;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
func(plainText, preview);
|
func().then();
|
||||||
return null;
|
return null;
|
||||||
}, 300)(plainText, preview);
|
}, 300)();
|
||||||
},
|
},
|
||||||
forceSync: true, // Avoid validation error on generic create view
|
forceSync: true, // Avoid validation error on generic create view
|
||||||
imageTexts: {
|
imageTexts: {
|
||||||
@@ -222,9 +225,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submits: HTMLInputElement[] = Array.from(
|
const submits: HTMLInputElement[] = Array.from(
|
||||||
textarea.closest("form").querySelectorAll('input[type="submit"]'),
|
(textarea.closest("form") as HTMLFormElement).querySelectorAll(
|
||||||
|
'input[type="submit"]',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const parentDiv = textarea.parentElement.parentElement;
|
const parentDiv = textarea.parentElement?.parentElement as HTMLElement;
|
||||||
|
|
||||||
function checkMarkdownInput(event: Event) {
|
function checkMarkdownInput(event: Event) {
|
||||||
// an attribute is null if it does not exist, else a string
|
// an attribute is null if it does not exist, else a string
|
||||||
@@ -249,6 +254,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@registerComponent("markdown-input")
|
@registerComponent("markdown-input")
|
||||||
|
// biome-ignore lint/correctness/noUnusedVariables: it is used in jinja
|
||||||
class MarkdownInput extends inheritHtmlElement("textarea") {
|
class MarkdownInput extends inheritHtmlElement("textarea") {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|||||||
@@ -1,18 +1,136 @@
|
|||||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
import {
|
||||||
|
type InheritedHtmlElement,
|
||||||
|
inheritHtmlElement,
|
||||||
|
registerComponent,
|
||||||
|
} from "#core:utils/web-components";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ElementOnce web components
|
||||||
|
*
|
||||||
|
* Those elements ensures that their content is always included only once on a document
|
||||||
|
* They are compatible with elements that are not managed with our Web Components
|
||||||
|
**/
|
||||||
|
export interface ElementOnce<K extends keyof HTMLElementTagNameMap>
|
||||||
|
extends InheritedHtmlElement<K> {
|
||||||
|
getElementQuerySelector(): string;
|
||||||
|
refresh(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an abstract class for ElementOnce types Web Components
|
||||||
|
**/
|
||||||
|
export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
||||||
|
abstract class ElementOnceImpl
|
||||||
|
extends inheritHtmlElement(tagName)
|
||||||
|
implements ElementOnce<K>
|
||||||
|
{
|
||||||
|
abstract getElementQuerySelector(): string;
|
||||||
|
|
||||||
|
clearNode() {
|
||||||
|
while (this.firstChild) {
|
||||||
|
this.removeChild(this.lastChild as ChildNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.clearNode();
|
||||||
|
if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) {
|
||||||
|
this.appendChild(this.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback(false);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
// The MutationObserver can't see web components being removed
|
||||||
|
// It also can't see if something is removed inside after the component gets deleted
|
||||||
|
// We need to manually clear the containing node to trigger the observer
|
||||||
|
this.clearNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ElementOnceImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set of ElementOnce type components to refresh with the observer
|
||||||
|
const registeredComponents: Set<string> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to register ElementOnce types Web Components
|
||||||
|
* It's a wrapper around registerComponent that registers that component on
|
||||||
|
* a MutationObserver that activates a refresh on them when elements are removed
|
||||||
|
*
|
||||||
|
* You are not supposed to unregister an element
|
||||||
|
**/
|
||||||
|
export function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
|
||||||
|
registeredComponents.add(name);
|
||||||
|
return registerComponent(name, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh all ElementOnce components on the document based on the tag name of the removed element
|
||||||
|
const refreshElement = <
|
||||||
|
T extends keyof HTMLElementTagNameMap,
|
||||||
|
K extends keyof HTMLElementTagNameMap,
|
||||||
|
>(
|
||||||
|
components: HTMLCollectionOf<ElementOnce<T>>,
|
||||||
|
removedTagName: K,
|
||||||
|
) => {
|
||||||
|
for (const element of components) {
|
||||||
|
// We can't guess if an element is compatible before we get one
|
||||||
|
// We exit the function completely if it's not compatible
|
||||||
|
if (element.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Since we need to pause the observer, we make an helper to start it with consistent arguments
|
||||||
|
const startObserver = (observer: MutationObserver) => {
|
||||||
|
observer.observe(document, {
|
||||||
|
// We want to also listen for elements contained in the header (eg: link)
|
||||||
|
subtree: true,
|
||||||
|
childList: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh ElementOnce components when changes happens
|
||||||
|
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||||
|
// To avoid infinite recursion, we need to pause the observer while manipulation nodes
|
||||||
|
observer.disconnect();
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.removedNodes) {
|
||||||
|
if (node.nodeType !== node.ELEMENT_NODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const registered of registeredComponents) {
|
||||||
|
refreshElement(
|
||||||
|
document.getElementsByTagName(registered) as HTMLCollectionOf<
|
||||||
|
ElementOnce<"html"> // The specific tag doesn't really matter
|
||||||
|
>,
|
||||||
|
(node as HTMLElement).tagName as keyof HTMLElementTagNameMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We then resume the observer
|
||||||
|
startObserver(observer);
|
||||||
|
});
|
||||||
|
|
||||||
|
startObserver(observer);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web component used to import css files only once
|
* Web component used to import css files only once
|
||||||
* If called multiple times or the file was already imported, it does nothing
|
* If called multiple times or the file was already imported, it does nothing
|
||||||
**/
|
**/
|
||||||
@registerComponent("link-once")
|
@registerElementOnce("link-once")
|
||||||
export class LinkOnce extends inheritHtmlElement("link") {
|
export class LinkOnce extends elementOnce("link") {
|
||||||
connectedCallback() {
|
getElementQuerySelector(): string {
|
||||||
super.connectedCallback(false);
|
|
||||||
// We get href from node.attributes instead of node.href to avoid getting the domain part
|
// We get href from node.attributes instead of node.href to avoid getting the domain part
|
||||||
const href = this.node.attributes.getNamedItem("href").nodeValue;
|
return `link[href='${this.node.attributes.getNamedItem("href")?.nodeValue}']`;
|
||||||
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
|
|
||||||
this.appendChild(this.node);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,14 +138,10 @@ export class LinkOnce extends inheritHtmlElement("link") {
|
|||||||
* Web component used to import javascript files only once
|
* Web component used to import javascript files only once
|
||||||
* If called multiple times or the file was already imported, it does nothing
|
* If called multiple times or the file was already imported, it does nothing
|
||||||
**/
|
**/
|
||||||
@registerComponent("script-once")
|
@registerElementOnce("script-once")
|
||||||
export class ScriptOnce extends inheritHtmlElement("script") {
|
export class ScriptOnce extends inheritHtmlElement("script") {
|
||||||
connectedCallback() {
|
getElementQuerySelector(): string {
|
||||||
super.connectedCallback(false);
|
// We get href from node.attributes instead of node.src to avoid getting the domain part
|
||||||
// We get src from node.attributes instead of node.src to avoid getting the domain part
|
return `script[src='${this.node.attributes.getNamedItem("src")?.nodeValue}']`;
|
||||||
const src = this.node.attributes.getNamedItem("src").nodeValue;
|
|
||||||
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
|
|
||||||
this.appendChild(this.node);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ export class NfcInput extends inheritHtmlElement("input") {
|
|||||||
window.alert(gettext("Unsupported NFC card"));
|
window.alert(gettext("Unsupported NFC card"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
|
ndef.addEventListener("reading", (event) => {
|
||||||
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
|
|
||||||
this.removeAttribute("scan");
|
this.removeAttribute("scan");
|
||||||
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();
|
this.node.value = (event as NDEFReadingEvent).serialNumber
|
||||||
|
.replace(/:/g, "")
|
||||||
|
.toUpperCase();
|
||||||
/* Auto submit form, we need another button to not trigger our previously defined click event */
|
/* Auto submit form, we need another button to not trigger our previously defined click event */
|
||||||
const submit = document.createElement("button");
|
const submit = document.createElement("button");
|
||||||
this.node.appendChild(submit);
|
this.node.appendChild(submit);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { registerComponent } from "#core:utils/web-components";
|
|
||||||
import { html, render } from "lit-html";
|
import { html, render } from "lit-html";
|
||||||
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
|
||||||
|
import { registerComponent } from "#core:utils/web-components";
|
||||||
|
|
||||||
@registerComponent("ui-tab")
|
@registerComponent("ui-tab")
|
||||||
export class Tab extends HTMLElement {
|
export class Tab extends HTMLElement {
|
||||||
@@ -19,7 +19,7 @@ export class Tab extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (name === "title") {
|
if (name === "title") {
|
||||||
this.description = newValue;
|
this.description = newValue ?? "";
|
||||||
}
|
}
|
||||||
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
|
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ export class Tab extends HTMLElement {
|
|||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
?aria-selected=${this.active}
|
?aria-selected="${this.active}"
|
||||||
class="tab-header clickable ${this.active ? "active" : ""}"
|
class="tab-header clickable ${this.active ? "active" : ""}"
|
||||||
@click="${() => this.setActive(true)}"
|
@click="${() => this.setActive(true)}"
|
||||||
>
|
>
|
||||||
@@ -40,7 +40,7 @@ export class Tab extends HTMLElement {
|
|||||||
return html`
|
return html`
|
||||||
<section
|
<section
|
||||||
class="tab-section"
|
class="tab-section"
|
||||||
?hidden=${!this.active}
|
?hidden="${!this.active}"
|
||||||
>
|
>
|
||||||
${unsafeHTML(this.getContentHtml())}
|
${unsafeHTML(this.getContentHtml())}
|
||||||
</section>
|
</section>
|
||||||
@@ -79,15 +79,15 @@ export class Tab extends HTMLElement {
|
|||||||
|
|
||||||
@registerComponent("ui-tab-group")
|
@registerComponent("ui-tab-group")
|
||||||
export class TabGroup extends HTMLElement {
|
export class TabGroup extends HTMLElement {
|
||||||
private node: HTMLDivElement;
|
private node!: HTMLDivElement;
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.node = document.createElement("div");
|
this.node = document.createElement("div");
|
||||||
this.node.classList.add("tabs", "shadow");
|
this.node.classList.add("tabs", "shadow");
|
||||||
this.appendChild(this.node);
|
this.appendChild(this.node);
|
||||||
|
|
||||||
this.addEventListener("ui-tab-activated", (event: CustomEvent) => {
|
this.addEventListener("ui-tab-activated", (event) => {
|
||||||
const target = event.detail as Tab;
|
const target = (event as CustomEvent).detail as Tab;
|
||||||
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
|
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
|
||||||
if (tab !== target) {
|
if (tab !== target) {
|
||||||
tab.setActive(false);
|
tab.setActive(false);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
interface Config {
|
||||||
|
/**
|
||||||
|
* The prefix of the formset, in case it has been changed.
|
||||||
|
* See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
|
||||||
|
*/
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
|
||||||
|
type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
/**
|
||||||
|
* Alpine data element to allow the dynamic addition of forms to a formset.
|
||||||
|
*
|
||||||
|
* To use this, you need :
|
||||||
|
* - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
|
||||||
|
* - a template containing the empty form
|
||||||
|
* (that you can obtain jinja-side with `{{ formset.empty_form }}`),
|
||||||
|
* noted by `x-ref="formTemplate"`
|
||||||
|
* - a button with `@click="addForm"`
|
||||||
|
* - you may also have one or more buttons with `@click="removeForm(element)"`,
|
||||||
|
* where `element` is the HTML element containing the form.
|
||||||
|
*
|
||||||
|
* For an example of how this is used, you can have a look to
|
||||||
|
* `counter/templates/counter/product_form.jinja`
|
||||||
|
*/
|
||||||
|
Alpine.data("dynamicFormSet", (config?: Config) => ({
|
||||||
|
formContainer: undefined as unknown as HTMLElement,
|
||||||
|
nbForms: 0,
|
||||||
|
template: undefined as unknown as HTMLTemplateElement,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.formContainer = this.$refs.formContainer as HTMLElement;
|
||||||
|
this.nbForms = this.formContainer.children.length as number;
|
||||||
|
this.template = this.$refs.formTemplate as HTMLTemplateElement;
|
||||||
|
const prefix = config?.prefix ?? "form";
|
||||||
|
(
|
||||||
|
this.$root.querySelector(`#id_${prefix}-TOTAL_FORMS`) as HTMLFormElement
|
||||||
|
).setAttribute(":value", "nbForms");
|
||||||
|
},
|
||||||
|
|
||||||
|
addForm() {
|
||||||
|
this.formContainer.appendChild(document.importNode(this.template.content, true));
|
||||||
|
const newForm = this.formContainer.lastElementChild as Element;
|
||||||
|
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
|
||||||
|
"input, select, textarea",
|
||||||
|
);
|
||||||
|
for (const el of inputs) {
|
||||||
|
el.name = el.name.replace("__prefix__", this.nbForms.toString());
|
||||||
|
el.id = el.id.replace("__prefix__", this.nbForms.toString());
|
||||||
|
}
|
||||||
|
const labels: NodeListOf<HTMLLabelElement> = newForm.querySelectorAll("label");
|
||||||
|
for (const el of labels) {
|
||||||
|
el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
|
||||||
|
}
|
||||||
|
inputs[0].focus();
|
||||||
|
this.nbForms += 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeForm(container: HTMLDivElement) {
|
||||||
|
container.remove();
|
||||||
|
this.nbForms -= 1;
|
||||||
|
// adjust the id of remaining forms
|
||||||
|
for (let i = 0; i < this.nbForms; i++) {
|
||||||
|
const form = this.formContainer.children[i];
|
||||||
|
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
|
||||||
|
"input, select, textarea",
|
||||||
|
);
|
||||||
|
for (const el of inputs) {
|
||||||
|
el.name = el.name.replace(/\d+/, i.toString());
|
||||||
|
el.id = el.id.replace(/\d+/, i.toString());
|
||||||
|
}
|
||||||
|
const labels: NodeListOf<HTMLLabelElement> = form.querySelectorAll("label");
|
||||||
|
for (const el of labels) {
|
||||||
|
el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* For more detailed infos on how to use this file,
|
||||||
|
* check /docs/tutorial/front/localstorage.md,
|
||||||
|
* or https://ae-utbm.github.io/sith/tutorial/front/localstorage/
|
||||||
|
*/
|
||||||
|
|
||||||
|
// increment this number when a breaking change is made with localStorage
|
||||||
|
const CURRENT_LOCALSTORAGE_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove keys that are no longer used from localStorage
|
||||||
|
*/
|
||||||
|
export function expireOldStorage() {
|
||||||
|
const version = Number.parseInt(localStorage.getItem("version") ?? "0", 10);
|
||||||
|
if (version === CURRENT_LOCALSTORAGE_VERSION) {
|
||||||
|
// The cache schema is up-to-date. Nothing to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.removeItem("basket1");
|
||||||
|
// remove all storage items which key is in the form
|
||||||
|
// `userXXXPictures` or `userXXXPicturesNumber`
|
||||||
|
Object.keys(localStorage)
|
||||||
|
.filter(
|
||||||
|
(key) =>
|
||||||
|
key.startsWith("user") &&
|
||||||
|
(key.endsWith("Pictures") || key.endsWith("PicturesNumber")),
|
||||||
|
)
|
||||||
|
.forEach((key) => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
localStorage.setItem("version", CURRENT_LOCALSTORAGE_VERSION.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionedStorageItem<T> {
|
||||||
|
version?: number;
|
||||||
|
val: T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const versionedLocalStorage = {
|
||||||
|
...localStorage,
|
||||||
|
/**
|
||||||
|
* set this item in localStorage, alongside its version.
|
||||||
|
*
|
||||||
|
* Note: this expects an object, not a JSON string, because the parsing
|
||||||
|
* into JSON needs to be done inside the function.
|
||||||
|
*/
|
||||||
|
setItem<T>(key: string, value: T, { version }: { version: number }) {
|
||||||
|
localStorage.setItem(key, JSON.stringify({ version: version, val: value }));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the item linked with the given key and version from localStorage.
|
||||||
|
*
|
||||||
|
* Note: if the given key exists in localStorage but doesn't satisfy
|
||||||
|
* the given version, it will be cleared from cache.
|
||||||
|
*
|
||||||
|
* @return the object if found and with the good version, else null;
|
||||||
|
*/
|
||||||
|
getItem<T>(key: string, { version }: { version: number }): T | null {
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
if (!stored) {
|
||||||
|
// this key doesn't exist, return null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const obj: VersionedStorageItem<T> = JSON.parse(stored);
|
||||||
|
if (obj.version !== version || obj.val === undefined) {
|
||||||
|
// The version is wrong, return null and remove this item from cache
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return obj.val;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { exportToHtml } from "#core:utils/globals";
|
function showMenu() {
|
||||||
|
const navbar = document.getElementById("navbar-content") as HTMLElement;
|
||||||
exportToHtml("showMenu", () => {
|
|
||||||
const navbar = document.getElementById("navbar-content");
|
|
||||||
const current = navbar.getAttribute("mobile-display");
|
const current = navbar.getAttribute("mobile-display");
|
||||||
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
|
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
|
||||||
});
|
}
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
function navbarInit() {
|
||||||
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
|
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
|
||||||
const isDesktop = () => {
|
const isDesktop = () => {
|
||||||
return window.innerWidth >= 500;
|
return window.innerWidth >= 500;
|
||||||
@@ -22,9 +20,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
item.removeAttribute("open");
|
item.removeAttribute("open");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
item.addEventListener("click", (event: MouseEvent) => {
|
item.addEventListener("click", (event) => {
|
||||||
// Don't close when clicking on desktop mode
|
// Don't close when clicking on desktop mode
|
||||||
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
|
if (
|
||||||
|
(event.target as HTMLElement).nodeName !== "SUMMARY" ||
|
||||||
|
(event as MouseEvent).detail === 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,4 +34,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
Object.assign(document, { showMenu });
|
||||||
|
document.addEventListener("alpine:init", navbarInit);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-expect-error 2307 this dependency does exist, but it's a little bit wacky
|
||||||
import clip from "@arendjr/text-clipper";
|
import clip from "@arendjr/text-clipper";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -26,7 +27,7 @@ function showMore(element: HTMLElement) {
|
|||||||
const fullContent = element.innerHTML;
|
const fullContent = element.innerHTML;
|
||||||
const clippedContent = clip(
|
const clippedContent = clip(
|
||||||
element.innerHTML,
|
element.innerHTML,
|
||||||
Number.parseInt(element.getAttribute("show-more") as string),
|
Number.parseInt(element.getAttribute("show-more") as string, 10),
|
||||||
{
|
{
|
||||||
html: true,
|
html: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
type Placement,
|
|
||||||
autoPlacement,
|
autoPlacement,
|
||||||
computePosition,
|
computePosition,
|
||||||
flip,
|
flip,
|
||||||
offset,
|
offset,
|
||||||
|
type Placement,
|
||||||
size,
|
size,
|
||||||
} from "@floating-ui/dom";
|
} from "@floating-ui/dom";
|
||||||
|
|
||||||
@@ -70,15 +70,16 @@ function createTooltip(element: HTMLElement) {
|
|||||||
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
|
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
|
||||||
// Update tooltip status and set it's attributes and content
|
// Update tooltip status and set it's attributes and content
|
||||||
tooltip.setAttribute("tooltip-status", status);
|
tooltip.setAttribute("tooltip-status", status);
|
||||||
tooltip.innerText = element.getAttribute("tooltip");
|
tooltip.innerText = element.getAttribute("tooltip") as string;
|
||||||
|
|
||||||
for (const attributes of [
|
for (const attributes of [
|
||||||
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
|
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
|
||||||
{ src: "tooltip-id", dst: "id", default: [] },
|
{ src: "tooltip-id", dst: "id", default: [] },
|
||||||
]) {
|
]) {
|
||||||
const populated = attributes.default;
|
const populated = attributes.default;
|
||||||
if (element.hasAttribute(attributes.src)) {
|
const attr = element.getAttribute(attributes.src);
|
||||||
populated.push(...element.getAttribute(attributes.src).split(" "));
|
if (attr !== null) {
|
||||||
|
populated.push(...attr.split(" "));
|
||||||
}
|
}
|
||||||
tooltip.setAttribute(attributes.dst, populated.join(" "));
|
tooltip.setAttribute(attributes.dst, populated.join(" "));
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ function getTooltip(element: HTMLElement) {
|
|||||||
return tooltip;
|
return tooltip;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tooltipMouseover(event: MouseEvent) {
|
function tooltipMouseover(event: Event) {
|
||||||
// We get the closest tooltip to have a consistent behavior
|
// We get the closest tooltip to have a consistent behavior
|
||||||
// when hovering over a child element of a tooltip marked element
|
// when hovering over a child element of a tooltip marked element
|
||||||
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
|
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
|
||||||
@@ -111,7 +112,7 @@ function tooltipMouseover(event: MouseEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function tooltipMouseout(event: MouseEvent) {
|
function tooltipMouseout(event: Event) {
|
||||||
// We get the closest tooltip to have a consistent behavior
|
// We get the closest tooltip to have a consistent behavior
|
||||||
// when hovering over a child element of a tooltip marked element
|
// when hovering over a child element of a tooltip marked element
|
||||||
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
|
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
|
||||||
@@ -141,7 +142,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
|
|||||||
}
|
}
|
||||||
} else if (tooltips.has(target)) {
|
} else if (tooltips.has(target)) {
|
||||||
// Remove corresponding tooltip
|
// Remove corresponding tooltip
|
||||||
tooltips.get(target).remove();
|
tooltips.get(target)?.remove();
|
||||||
tooltips.delete(target);
|
tooltips.delete(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +164,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tooltips.has(target)) {
|
if (tooltips.has(target)) {
|
||||||
tooltips.get(target).remove();
|
tooltips.get(target)?.remove();
|
||||||
tooltips.delete(target);
|
tooltips.delete(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
|
|
||||||
|
|
||||||
polyfillCountryFlagEmojis();
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import htmx from "htmx.org";
|
|
||||||
|
|
||||||
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
|
||||||
event.target.ariaBusy = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.addEventListener("htmx:afterRequest", (event) => {
|
|
||||||
event.originalTarget.ariaBusy = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.assign(window, { htmx });
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { exportToHtml } from "#core:utils/globals";
|
// biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation
|
||||||
// biome-ignore lint/style/noNamespaceImport: this is the recommended way from the documentation
|
|
||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
|
|
||||||
interface LoggedUser {
|
interface LoggedUser {
|
||||||
@@ -13,7 +12,7 @@ interface SentryOptions {
|
|||||||
user?: LoggedUser;
|
user?: LoggedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
exportToHtml("loadSentryPopup", (options: SentryOptions) => {
|
const loadSentryPopup = (options: SentryOptions) => {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: options.dsn,
|
dsn: options.dsn,
|
||||||
});
|
});
|
||||||
@@ -21,4 +20,5 @@ exportToHtml("loadSentryPopup", (options: SentryOptions) => {
|
|||||||
eventId: options.eventId,
|
eventId: options.eventId,
|
||||||
...(options.user ?? {}),
|
...(options.user ?? {}),
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
Object.assign(window, { loadSentryPopup });
|
||||||
|
|||||||
-4
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
// This has been modified to not trigger biome linting
|
// This has been modified to not trigger biome linting
|
||||||
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
|
||||||
interface Window {
|
interface Window {
|
||||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
NDEFMessage: NDEFMessage;
|
NDEFMessage: NDEFMessage;
|
||||||
@@ -28,7 +27,6 @@ declare interface NDEFMessageInit {
|
|||||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
declare type NDEFRecordDataSource = string | BufferSource | NDEFMessageInit;
|
declare type NDEFRecordDataSource = string | BufferSource | NDEFMessageInit;
|
||||||
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
|
||||||
interface Window {
|
interface Window {
|
||||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
NDEFRecord: NDEFRecord;
|
NDEFRecord: NDEFRecord;
|
||||||
@@ -57,7 +55,6 @@ declare interface NDEFRecordInit {
|
|||||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
|
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
|
||||||
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
|
||||||
interface Window {
|
interface Window {
|
||||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
NDEFReader: NDEFReader;
|
NDEFReader: NDEFReader;
|
||||||
@@ -74,7 +71,6 @@ declare class NDEFReader extends EventTarget {
|
|||||||
makeReadOnly: (options?: NDEFMakeReadOnlyOptions) => Promise<void>;
|
makeReadOnly: (options?: NDEFMakeReadOnlyOptions) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
|
||||||
interface Window {
|
interface Window {
|
||||||
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
NDEFReadingEvent: NDEFReadingEvent;
|
NDEFReadingEvent: NDEFReadingEvent;
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
import cytoscape, { type ElementDefinition, type Singular } from "cytoscape";
|
||||||
import cytoscape, {
|
|
||||||
type ElementDefinition,
|
|
||||||
type NodeSingular,
|
|
||||||
type Singular,
|
|
||||||
} from "cytoscape";
|
|
||||||
import cxtmenu from "cytoscape-cxtmenu";
|
import cxtmenu from "cytoscape-cxtmenu";
|
||||||
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
|
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
|
||||||
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
|
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||||
|
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
|
||||||
|
|
||||||
cytoscape.use(klay);
|
cytoscape.use(klay);
|
||||||
cytoscape.use(cxtmenu);
|
cytoscape.use(cxtmenu);
|
||||||
|
|
||||||
type GraphData = (
|
type GraphData = { data: UserProfileSchema | { source: number; target: number } }[];
|
||||||
| { data: UserProfileSchema }
|
|
||||||
| { data: { source: number; target: number } }
|
|
||||||
)[];
|
|
||||||
|
|
||||||
function isMobile() {
|
function isMobile() {
|
||||||
return window.innerWidth < 500;
|
return window.innerWidth < 500;
|
||||||
@@ -27,10 +20,8 @@ async function getGraphData(
|
|||||||
): Promise<GraphData> {
|
): Promise<GraphData> {
|
||||||
const data = (
|
const data = (
|
||||||
await familyGetFamilyGraph({
|
await familyGetFamilyGraph({
|
||||||
path: {
|
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
path: { user_id: userId },
|
||||||
user_id: userId,
|
|
||||||
},
|
|
||||||
query: {
|
query: {
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||||
godfathers_depth: godfathersDepth,
|
godfathers_depth: godfathersDepth,
|
||||||
@@ -39,6 +30,10 @@ async function getGraphData(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
).data;
|
).data;
|
||||||
|
if (data === undefined) {
|
||||||
|
console.error("Family graph request failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
...data.users.map((user) => {
|
...data.users.map((user) => {
|
||||||
return { data: user };
|
return { data: user };
|
||||||
@@ -155,7 +150,7 @@ function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: n
|
|||||||
{
|
{
|
||||||
content: '<i class="fa fa-external-link fa-2x"></i>',
|
content: '<i class="fa fa-external-link fa-2x"></i>',
|
||||||
select: (el) => {
|
select: (el) => {
|
||||||
window.open(el.data().profile_url, "_blank").focus();
|
window.open(el.data().profile_url, "_blank")?.focus();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -195,12 +190,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
godfathersDepth: 0,
|
godfathersDepth: 0,
|
||||||
godchildrenDepth: 0,
|
godchildrenDepth: 0,
|
||||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||||
graph: undefined as cytoscape.Core,
|
graph: undefined as unknown as cytoscape.Core,
|
||||||
graphData: {},
|
graphData: {} as GraphData,
|
||||||
isZoomEnabled: !isMobile(),
|
isZoomEnabled: !isMobile(),
|
||||||
|
|
||||||
getInitialDepth(prop: string) {
|
getInitialDepth(prop: string) {
|
||||||
const value = Number.parseInt(initialUrlParams.get(prop));
|
const value = Number.parseInt(initialUrlParams.get(prop) as string, 10);
|
||||||
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
||||||
return defaultDepth;
|
return defaultDepth;
|
||||||
}
|
}
|
||||||
@@ -223,8 +218,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
await delayedFetch();
|
await delayedFetch();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.$watch("reverse", async (value: number) => {
|
this.$watch("reverse", async (newValue, _oldValue) => {
|
||||||
updateQueryString("reverse", value.toString(), History.Replace);
|
updateQueryString("reverse", newValue.toString(), History.Replace);
|
||||||
await this.reverseGraph();
|
await this.reverseGraph();
|
||||||
});
|
});
|
||||||
this.$watch("graphData", async () => {
|
this.$watch("graphData", async () => {
|
||||||
@@ -259,9 +254,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async reverseGraph() {
|
async reverseGraph() {
|
||||||
this.graph.elements((el: NodeSingular) => {
|
this.graph
|
||||||
el.position({ x: -el.position().x, y: -el.position().y });
|
.elements()
|
||||||
});
|
.positions((el, _) => ({ x: -el.position().x, y: -el.position().y }));
|
||||||
this.graph.center(this.graph.elements());
|
this.graph.center(this.graph.elements());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ interface AlertParams {
|
|||||||
|
|
||||||
export class AlertMessage {
|
export class AlertMessage {
|
||||||
public open: boolean;
|
public open: boolean;
|
||||||
public success: boolean;
|
public success!: boolean;
|
||||||
public content: string;
|
public content: string;
|
||||||
private timeoutId?: number;
|
private timeoutId: number | null;
|
||||||
private readonly defaultDuration: number;
|
private readonly defaultDuration: number;
|
||||||
|
|
||||||
constructor(params?: { defaultDuration: number }) {
|
constructor(params?: { defaultDuration: number }) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { client, type Options } from "#openapi";
|
||||||
import type { Client, RequestResult, TDataShape } from "#openapi:client";
|
import type { Client, RequestResult, TDataShape } from "#openapi:client";
|
||||||
import { type Options, client } from "#openapi";
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -37,6 +37,10 @@ export const paginated = async <T>(
|
|||||||
queryParams.query.page = 1;
|
queryParams.query.page = 1;
|
||||||
|
|
||||||
const firstPage = (await endpoint(queryParams)).data;
|
const firstPage = (await endpoint(queryParams)).data;
|
||||||
|
if (firstPage === undefined) {
|
||||||
|
console.error(`Request to "${options?.url}" failed`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const results = firstPage.results;
|
const results = firstPage.results;
|
||||||
|
|
||||||
const nbElements = firstPage.count;
|
const nbElements = firstPage.count;
|
||||||
@@ -45,9 +49,9 @@ export const paginated = async <T>(
|
|||||||
if (nbPages > 1) {
|
if (nbPages > 1) {
|
||||||
const promises: Promise<T[]>[] = [];
|
const promises: Promise<T[]>[] = [];
|
||||||
for (let i = 2; i <= nbPages; i++) {
|
for (let i = 2; i <= nbPages; i++) {
|
||||||
const nextPage = structuredClone(queryParams);
|
const nextPage = structuredClone(queryParams) as Required<PaginatedRequest>;
|
||||||
nextPage.query.page = i;
|
nextPage.query.page = i;
|
||||||
promises.push(endpoint(nextPage).then((res) => res.data.results));
|
promises.push(endpoint(nextPage).then((res) => res.data?.results ?? []));
|
||||||
}
|
}
|
||||||
results.push(...(await Promise.all(promises)).flat());
|
results.push(...(await Promise.all(promises)).flat());
|
||||||
}
|
}
|
||||||
@@ -61,8 +65,7 @@ interface Request extends TDataShape {
|
|||||||
interface InterceptorOptions {
|
interface InterceptorOptions {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
export type GenericEndpoint = <ThrowOnError extends boolean = false>(
|
||||||
type GenericEndpoint = <ThrowOnError extends boolean = false>(
|
|
||||||
options?: Options<Request, ThrowOnError>,
|
options?: Options<Request, ThrowOnError>,
|
||||||
) => RequestResult<unknown, unknown, ThrowOnError>;
|
) => RequestResult<unknown, unknown, ThrowOnError>;
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ type GenericEndpoint = <ThrowOnError extends boolean = false>(
|
|||||||
**/
|
**/
|
||||||
export const makeUrl = async (endpoint: GenericEndpoint) => {
|
export const makeUrl = async (endpoint: GenericEndpoint) => {
|
||||||
let url = "";
|
let url = "";
|
||||||
const interceptor = (_request: undefined, options: InterceptorOptions) => {
|
const interceptor = (_request: Request, options: InterceptorOptions) => {
|
||||||
url = options.url;
|
url = options.url;
|
||||||
throw new Error("We don't want to send the request");
|
throw new Error("We don't want to send the request");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { NestedKeyOf } from "#core:utils/types";
|
import type { NestedKeyOf } from "#core:types/nested-key";
|
||||||
|
|
||||||
interface StringifyOptions<T extends object> {
|
interface StringifyOptions<T extends object> {
|
||||||
/** The columns to include in the resulting CSV. */
|
/** The columns to include in the resulting CSV. */
|
||||||
@@ -7,14 +7,14 @@ interface StringifyOptions<T extends object> {
|
|||||||
titleRow?: readonly string[];
|
titleRow?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
|
function getNested<T extends { [key: string]: unknown }>(obj: T, key: NestedKeyOf<T>) {
|
||||||
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
|
const path = key.split(".");
|
||||||
let res = obj[path.shift() as keyof T];
|
let res = obj[path.shift() as string] as { [key: string]: unknown } | undefined;
|
||||||
for (const node of path) {
|
for (const node of path) {
|
||||||
if (res === null) {
|
if (res === undefined) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
res = res[node];
|
res = res[node] as { [key: string]: unknown } | undefined;
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -29,18 +29,21 @@ function sanitizeCell(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const csv = {
|
export const csv = {
|
||||||
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
|
stringify: <T extends { [key: string]: unknown }>(
|
||||||
const columns = options.columns;
|
objs: T[],
|
||||||
|
options?: StringifyOptions<T>,
|
||||||
|
) => {
|
||||||
|
const columns = options?.columns;
|
||||||
const content = objs
|
const content = objs
|
||||||
.map((obj) => {
|
.map((obj) => {
|
||||||
return columns
|
return (columns ?? [])
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
return sanitizeCell((getNested(obj, col) ?? "").toString());
|
return sanitizeCell((getNested(obj, col) ?? "").toString());
|
||||||
})
|
})
|
||||||
.join(",");
|
.join(",");
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (!options.titleRow) {
|
if (!options?.titleRow) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
const firstRow = options.titleRow.map(sanitizeCell).join(",");
|
const firstRow = options.titleRow.map(sanitizeCell).join(",");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user