Compare commits
544 Commits
v0.0.1-1
...
59ec73c8b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ec73c8b7 | ||
|
|
986518b2e4 | ||
|
|
f02d3b8085 | ||
|
|
3347df1911 | ||
|
|
a299783a6c | ||
|
|
14a544c9c4 | ||
|
|
ad5dbcc24b | ||
|
|
3a57f2f1d7 | ||
|
|
a16798553e | ||
| e22c7e7dd2 | |||
| 3cbeab37fb | |||
| 3f26f5452f | |||
| 98a52f3c25 | |||
| 1705320ada | |||
| 9fab8046f6 | |||
| eec49e2bd5 | |||
| cfc644e612 | |||
| ec67dacbbc | |||
| af9cef7b5b | |||
| 3a6ad5fd73 | |||
| bb3c9680a8 | |||
| 1be0c7b9fc | |||
| 8b5c843371 | |||
| b0a57b72e0 | |||
| 8a462f6610 | |||
| f042653b86 | |||
| 3cb08ad968 | |||
| c8b8d70c81 | |||
| 1d6bec5b8b | |||
| 1c623d49e3 | |||
| 8f08bf740a | |||
| e8c6119def | |||
|
|
4d5242a479 | ||
| 4e1b200ab3 | |||
| 859feba09e | |||
| 8f0caf6c98 | |||
| 95e3af4f76 | |||
| cf28a311ed | |||
| bbe8c559eb | |||
| 28afbb4279 | |||
| fd7ca2fe6e | |||
| 99ccd937a4 | |||
|
|
d5a2d1b309 | ||
|
|
34cfde795a | ||
|
|
43b5312e35 | ||
|
|
af40974018 | ||
|
|
a4d17492d2 | ||
|
|
a4403a0d33 | ||
|
|
218191a718 | ||
|
|
721387c994 | ||
|
|
3901cc152e | ||
|
|
177fde9e4b | ||
|
|
dc2ece32a0 | ||
|
|
71233ebb75 | ||
|
|
b92127070b | ||
|
|
c22a59c70c | ||
|
|
9e3842fc7b | ||
|
|
3ba1de00e0 | ||
|
|
d0d37f08b9 | ||
|
|
9537eef58d | ||
| bc6dcc5bb7 | |||
|
|
6da7860b59 | ||
| 7202459284 | |||
| 6cc430d422 | |||
|
|
4128d617a7 | ||
|
|
a51e44847e | ||
|
|
45a6ab7249 | ||
|
|
a363fb9ef8 | ||
|
|
2c246ac40a | ||
|
|
43b7b93ffa | ||
|
|
cabc2c6a4a | ||
|
|
226b10b5a1 | ||
|
|
b24748a7b6 | ||
|
|
11065cd007 | ||
|
|
05d364bcd4 | ||
|
|
57a553037b | ||
|
|
5ed356a61c | ||
|
|
5da660b4c3 | ||
|
|
d439002241 | ||
|
|
4246cde484 | ||
|
|
a902205960 | ||
|
|
355b73775f | ||
|
|
81744b5c5e | ||
|
|
735fc69562 | ||
|
|
17a5fd2086 | ||
|
|
9986716e9e | ||
|
|
759c78ce87 | ||
|
|
365791cd35 | ||
|
|
08b680f030 | ||
|
|
4d6be819e1 | ||
|
|
d803f49713 | ||
|
|
01114b4ca9 | ||
|
|
873e81d95d | ||
|
|
d809dcb361 | ||
|
|
fa017ce290 | ||
|
|
c2cf310f89 | ||
| e1350d722c | |||
| af1019f01c | |||
|
|
3433d85471 | ||
|
|
a8b3a14044 | ||
|
|
c944cadff3 | ||
|
|
0a5debe45a | ||
|
|
dbead3d719 | ||
|
|
34065bc288 | ||
|
|
6236657d9a | ||
|
|
68a7e07c58 | ||
|
|
eca635a175 | ||
|
|
bcdbc49aa8 | ||
|
|
419d01f74d | ||
|
|
9b131824e8 | ||
|
|
0286ee351e | ||
|
|
ee59c3e561 | ||
|
|
b9c3204db0 | ||
|
|
3324564160 | ||
|
|
7821f25b61 | ||
|
|
8e38576dbc | ||
|
|
e118ac533d | ||
|
|
939f55d9aa | ||
|
|
c34aad68f1 | ||
|
|
c2c7adf950 | ||
|
|
a5bf017c30 | ||
|
|
a9f21dd4b9 | ||
|
|
4663445fb8 | ||
|
|
2d85991db0 | ||
|
|
69ecc26236 | ||
|
|
44c3f8eb5b | ||
|
|
da882a9a24 | ||
|
|
06618b1e27 | ||
|
|
5fe052648d | ||
|
|
fe213d4acd | ||
|
|
3a99d1a936 | ||
|
|
0f45ae94af | ||
|
|
11f89307eb | ||
|
|
c9d5ab22c8 | ||
|
|
ce74879d15 | ||
|
|
0120feff33 | ||
|
|
7eb29b271a | ||
|
|
2015065af4 | ||
|
|
e7f6389ca3 | ||
|
|
767730831e | ||
|
|
556b1fd4b9 | ||
|
|
577ac3f463 | ||
|
|
f2e99821f7 | ||
|
|
065f67c45e | ||
|
|
e2761584a3 | ||
|
|
e4a33ad6aa | ||
|
|
cee5e31646 | ||
|
|
b386364cd6 | ||
|
|
92bc3e59ae | ||
|
|
174161b667 | ||
|
|
4ec1d4405f | ||
|
|
f911fc2c10 | ||
|
|
866f9ca2fd | ||
|
|
1326d5b4ef | ||
|
|
ad219cdf4b | ||
|
|
d87a0aacfb | ||
|
|
3f9011aac1 | ||
|
|
74117ccf5b | ||
|
|
e992717c45 | ||
|
|
070c89b582 | ||
|
|
07913bbf61 | ||
|
|
3fcd1881a5 | ||
|
|
b43d89cf94 | ||
| 7da8c1ae2f | |||
|
|
eb9187a965 | ||
|
|
45302341e2 | ||
|
|
c93194ba3e | ||
|
|
f2dafd6fe8 | ||
|
|
8e96702d8e | ||
|
|
2a67217008 | ||
|
|
9bff1a43b3 | ||
|
|
016f9896b7 | ||
|
|
74b44dd8e8 | ||
|
|
b709c8252c | ||
|
|
736b34231a | ||
|
|
ec200a3819 | ||
|
|
554340dd64 | ||
|
|
e860202af8 | ||
|
|
50af5ce44d | ||
|
|
86b104f007 | ||
|
|
7496442276 | ||
|
|
4c0df73e74 | ||
|
|
a69bf21fbb | ||
|
|
3ade00fe08 | ||
|
|
14c53aa856 | ||
|
|
0e4375b561 | ||
|
|
7bdd9239eb | ||
|
|
ce430f0f22 | ||
|
|
bf1c2e2a29 | ||
| 5674b4acbf | |||
| dd8a818aa9 | |||
|
|
40e76ad5a4 | ||
|
|
824e5bde0d | ||
|
|
e449bc26ac | ||
|
|
e9504775d7 | ||
|
|
591b600b17 | ||
|
|
ffc2f1c6ab | ||
|
|
7202fb7e5e | ||
|
|
4696b75133 | ||
|
|
a7c2e6dc56 | ||
|
|
7527fb7967 | ||
|
|
47bfc2927e | ||
|
|
e7881fe532 | ||
|
|
372bd8eb20 | ||
|
|
5415e3435e | ||
|
|
8685e65d22 | ||
|
|
8662d085f3 | ||
|
|
bfc2841c34 | ||
|
|
7751e56a8c | ||
|
|
b0dea8d7db | ||
|
|
861e272fad | ||
|
|
af6272cabf | ||
|
|
50c18ec5d4 | ||
|
|
766e73d1c8 | ||
|
|
ab63d25cdc | ||
|
|
c0da0c3784 | ||
|
|
4342a6b817 | ||
|
|
20d91084f6 | ||
|
|
b1e0c2d3cb | ||
|
|
d8c187a63c | ||
|
|
ea73e6a983 | ||
|
|
5de35babf6 | ||
|
|
14017f7b49 | ||
|
|
05e89ea490 | ||
|
|
d3ad2a38bf | ||
|
|
2b7f306dcf | ||
|
|
6b070968c4 | ||
|
|
2682844c26 | ||
|
|
addc2af505 | ||
|
|
f08f0dd98c | ||
|
|
06e8fc05b3 | ||
|
|
76239a9dea | ||
|
|
a92d91c1dd | ||
|
|
fc108bceb5 | ||
|
|
8b1057d824 | ||
|
|
3cddb79e4f | ||
|
|
899bb6eb3a | ||
|
|
f9ffd083af | ||
|
|
92c257abfc | ||
|
|
95cc506abf | ||
|
|
7762cba541 | ||
|
|
5d977c644b | ||
|
|
04995f4ab4 | ||
|
|
35d9982542 | ||
|
|
dd65230636 | ||
|
|
268f8d2e85 | ||
|
|
b4f1e43f5f | ||
|
|
87000bf90c | ||
|
|
32f491f978 | ||
|
|
ee1a163395 | ||
|
|
f4e10ef209 | ||
|
|
ff0f2a3601 | ||
|
|
a4f8275049 | ||
|
|
8d0106c56d | ||
|
|
bfcc224a0f | ||
|
|
6a8305b640 | ||
|
|
8b9483952e | ||
|
|
0878b34047 | ||
|
|
7a2685771d | ||
|
|
16065df4c4 | ||
|
|
1e73d85600 | ||
|
|
27e14fdd1d | ||
|
|
5c90e020b6 | ||
|
|
25d1967fd8 | ||
|
|
2d22fd2583 | ||
|
|
5c941ea955 | ||
|
|
946e0424fe | ||
|
|
f5e65b9404 | ||
|
|
466dface63 | ||
|
|
d526f392b8 | ||
|
|
bf57b6b4e3 | ||
|
|
c3c7a750ba | ||
|
|
df8e446c42 | ||
|
|
d15d83eea2 | ||
|
|
0187f1d654 | ||
|
|
a3bf47ecc3 | ||
|
|
2edbd6475f | ||
|
|
cd8d418371 | ||
|
|
c78b5315bb | ||
|
|
b6502fedfc | ||
|
|
e3b180fba7 | ||
|
|
529130a622 | ||
|
|
68f235d605 | ||
|
|
ea46dd43e1 | ||
|
|
4b99b109bd | ||
|
|
028ae3c26e | ||
|
|
71b14411e5 | ||
|
|
f1744fae99 | ||
|
|
79c6f7d0b1 | ||
|
|
80651bc3c7 | ||
|
|
4602fb7749 | ||
|
|
40381774b4 | ||
|
|
cc988d5934 | ||
|
|
883562c532 | ||
|
|
5e01a735d3 | ||
|
|
4988ad9a5f | ||
|
|
d321521ef1 | ||
|
|
ac88869f52 | ||
|
|
719735b6d7 | ||
|
|
1f2fc60683 | ||
|
|
977b8630fb | ||
|
|
5256e9d17b | ||
|
|
e7c0dae7a1 | ||
|
|
e2468c299d | ||
|
|
feb2679d91 | ||
|
|
4955516c93 | ||
|
|
b61c262179 | ||
|
|
4f40bb06b3 | ||
|
|
97cebbdd49 | ||
|
|
840c466b0c | ||
|
|
9722e4fb7e | ||
|
|
012b99d7eb | ||
|
|
9d777f4fc5 | ||
|
|
1befc2f87d | ||
|
|
960715f5d7 | ||
|
|
f138cabd53 | ||
|
|
479e601de1 | ||
|
|
82c84b5ce6 | ||
|
|
ee40ee101c | ||
|
|
5188f80948 | ||
|
|
fe027a3bc7 | ||
|
|
87d9a8228c | ||
|
|
c9f5a37e1f | ||
|
|
4dfd1fa45f | ||
|
|
01fa938a27 | ||
|
|
ea5f9a3f27 | ||
|
|
5043a54bbb | ||
|
|
29b7ccf02f | ||
|
|
a31683f08f | ||
|
|
93a0c32736 | ||
|
|
1e04039387 | ||
|
|
a224ec1c2a | ||
|
|
740c02b42b | ||
|
|
8c627affe5 | ||
|
|
cf9ac666b9 | ||
|
|
a2950644c1 | ||
|
|
3dfc8c6be6 | ||
|
|
82ab7483e0 | ||
|
|
507ce1e5dc | ||
|
|
ae2c3e66bf | ||
|
|
462570da48 | ||
|
|
b111e5b4df | ||
|
|
9d5630bde3 | ||
|
|
dc8bfacdf6 | ||
|
|
4939d10165 | ||
|
|
dd05d6476f | ||
|
|
629c24c06b | ||
|
|
da01bda9bc | ||
|
|
8590eba918 | ||
|
|
3abad9e151 | ||
|
|
6bb0c97c37 | ||
|
|
a5948e3e7e | ||
|
|
8337be6469 | ||
|
|
1cd4f62004 | ||
|
|
9142dc1413 | ||
|
|
a612d4c25c | ||
|
|
8cae4a3245 | ||
| 8473c8ee9f | |||
| cb49d6190f | |||
| 6b8cb894c8 | |||
|
|
511e38cd3e | ||
|
|
c2b6f38c47 | ||
|
|
27589c2b7c | ||
|
|
3f67007f2f | ||
|
|
beed40868d | ||
|
|
76194e2f57 | ||
|
|
79ba2068ec | ||
|
|
cfae8571de | ||
|
|
2df64bbe2e | ||
|
|
0c1b9aebf5 | ||
|
|
1049a69cb8 | ||
|
|
085743c7fb | ||
|
|
c28e6f394d | ||
|
|
9bbf32f84e | ||
|
|
c92f45fb7f | ||
|
|
933084da4f | ||
|
|
f7bad7804b | ||
|
|
71f528f974 | ||
|
|
77bb4594a4 | ||
|
|
ef108950b2 | ||
|
|
048ed158a1 | ||
|
|
ce7a5406a5 | ||
|
|
b46cc7d295 | ||
|
|
bdee9cd3aa | ||
|
|
c3c865f074 | ||
|
|
1af4e4d040 | ||
|
|
2b33701e35 | ||
|
|
5ddbd2b1ed | ||
|
|
1ab52556f1 | ||
|
|
969a0565fa | ||
|
|
c97f419b20 | ||
|
|
962f4e7011 | ||
|
|
c1ebeabe0a | ||
|
|
1208088de5 | ||
| ebc3f8f5a7 | |||
|
|
84ca3aee73 | ||
| 5777e25622 | |||
|
|
0a44753eb2 | ||
|
|
29ccd252b8 | ||
|
|
50de359838 | ||
|
|
f4523d0c95 | ||
|
|
978bcbe051 | ||
|
|
437f7a26e3 | ||
|
|
b75200b487 | ||
|
|
0b277fef7b | ||
|
|
de0f825988 | ||
|
|
4be1578568 | ||
|
|
5dc6c947d1 | ||
|
|
79c8fa916b | ||
|
|
247e06bad5 | ||
|
|
e25ea1e4fb | ||
|
|
b8fe02c96f | ||
|
|
4f8c5b27d1 | ||
|
|
abca2e505d | ||
|
|
132c04215e | ||
|
|
54fe9fd7a7 | ||
|
|
22c146b313 | ||
|
|
a1fee7caaf | ||
|
|
8f6669cb41 | ||
|
|
35396afecb | ||
| 67d4fd0024 | |||
| e1d1ec67c3 | |||
| a81737b2ce | |||
|
|
40a3d60da0 | ||
|
|
9a844fc539 | ||
|
|
396a56e773 | ||
|
|
c6b089472a | ||
|
|
1de3171183 | ||
|
|
18e2d376c2 | ||
|
|
159b52099e | ||
|
|
643757e45e | ||
|
|
9e3068a722 | ||
|
|
b9b91ff82b | ||
|
|
a5025b94ff | ||
|
|
5c6e2f6540 | ||
|
|
f913aeac60 | ||
|
|
359b5fb61b | ||
|
|
5519f7eef5 | ||
|
|
4b76df795b | ||
|
|
81985f7f84 | ||
|
|
50d67d5b1a | ||
|
|
e5e498a5a9 | ||
|
|
4cea094465 | ||
|
|
b7b6453b42 | ||
|
|
7e69610981 | ||
|
|
bc6f64e546 | ||
|
|
e5ef1309e7 | ||
|
|
6b2469778f | ||
|
|
07d06ded60 | ||
|
|
a2c333014e | ||
|
|
04c187d3d3 | ||
|
|
8db5cd82ac | ||
|
|
f2811148f1 | ||
|
|
c8a5db6715 | ||
|
|
e806976453 | ||
|
|
d8d786aed8 | ||
|
|
b17a12b9fd | ||
|
|
1a98b7165d | ||
|
|
0357a63dcf | ||
|
|
ddbd224e06 | ||
|
|
a417889810 | ||
|
|
d42d973ffd | ||
|
|
7dc49fe160 | ||
|
|
5e782ba170 | ||
|
|
be986fc8f5 | ||
|
|
cd06fc3ca4 | ||
|
|
e4322f2bc6 | ||
|
|
bb667a2cbd | ||
|
|
0d5b170cac | ||
|
|
34205f0e65 | ||
|
|
452f2271cd | ||
| 7812209818 | |||
|
|
04bc3773e1 | ||
| 1d583ad801 | |||
|
|
c9ef1c488b | ||
| c63995d750 | |||
| 7f68b1647e | |||
|
|
6f7d0069cc | ||
|
|
a68aa031bb | ||
|
|
730330cba9 | ||
|
|
5a898c5b7a | ||
|
|
74ae7642e5 | ||
|
|
111a63d3af | ||
|
|
57a3866ec8 | ||
| 48f1841649 | |||
| 0d9e56dfa1 | |||
|
|
d899672a2b | ||
|
|
03d4370c8a | ||
|
|
f30cd0f2fe | ||
|
|
4ec33569a0 | ||
|
|
1ab1b36811 | ||
|
|
dea0309cfd | ||
|
|
22bc8bd01d | ||
|
|
78fcdce327 | ||
|
|
258d111133 | ||
|
|
cc1dad0d7d | ||
|
|
db6f70349e | ||
|
|
a44a61c718 | ||
|
|
aa865baf3b | ||
|
|
a84b130822 | ||
|
|
983114575d | ||
|
|
955196dd92 | ||
|
|
8ae9068ffa | ||
| a3d47eb368 | |||
| b0095c3b97 | |||
|
|
98f22e0bd1 | ||
|
|
62939a9e9a | ||
|
|
ae89f55446 | ||
|
|
3ebb364322 | ||
|
|
470cd32745 | ||
|
|
1f609b6dba | ||
|
|
f71697b6db | ||
|
|
6dc712f76e | ||
|
|
69b1e9495f | ||
|
|
114bf5c047 | ||
|
|
d8233cb6e5 | ||
|
|
7a9042ffb2 | ||
|
|
1df8e44e4d | ||
|
|
c09edd04b0 | ||
|
|
115d15a055 | ||
| 65a09b2305 | |||
| d48654f5b6 | |||
|
|
1c88e5c00b | ||
|
|
69f1b4d1c8 | ||
|
|
8c9f0f1a6a | ||
|
|
804b80bbf5 | ||
|
|
45290a6147 | ||
|
|
377e592f90 | ||
|
|
133b91073d | ||
|
|
6431393baf | ||
|
|
d3e50305a7 | ||
|
|
53394469de | ||
|
|
9dcd144b34 | ||
|
|
4ef183e2a9 | ||
|
|
3b94f93892 | ||
| 1bc96a1979 | |||
|
|
2c6887095d | ||
|
|
94eceb76ab | ||
|
|
bd0f6003f5 | ||
|
|
58e0929a4c | ||
|
|
95c11589e2 | ||
|
|
b590ebc6b6 | ||
|
|
d1c8970108 | ||
|
|
eaa5fde7a5 |
165
.env.example
@@ -1,20 +1,171 @@
|
||||
# Domyślny port aplikacji
|
||||
# APP_PORT:
|
||||
# Domyślny port, na którym uruchamiana jest aplikacja Flask
|
||||
# Domyślnie: 8000
|
||||
APP_PORT=8000
|
||||
|
||||
# Klucz bezpieczeństwa Flask
|
||||
# SECRET_KEY:
|
||||
# Klucz używany przez Flask do zabezpieczenia sesji, tokenów i formularzy
|
||||
# Powinien być długi i trudny do odgadnięcia
|
||||
SECRET_KEY=supersekretnyklucz123
|
||||
|
||||
# Hasło główne do systemu
|
||||
# SYSTEM_PASSWORD:
|
||||
# Hasło główne administratora systemowego, używane np. przy inicjalizacji
|
||||
# Domyślnie: admin
|
||||
SYSTEM_PASSWORD=admin
|
||||
|
||||
# Domyślny admin (login i hasło)
|
||||
# DEFAULT_ADMIN_USERNAME:
|
||||
# Domyślna nazwa użytkownika administratora (tworzona przy starcie)
|
||||
# Domyślnie: admin
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
|
||||
# DEFAULT_ADMIN_PASSWORD:
|
||||
# Domyślne hasło administratora
|
||||
# Domyślnie: admin123
|
||||
DEFAULT_ADMIN_PASSWORD=admin123
|
||||
|
||||
# Katalog wgrywanych plików
|
||||
# UPLOAD_FOLDER:
|
||||
# Ścieżka (względna) do katalogu, gdzie zapisywane są wgrywane pliki
|
||||
# Domyślnie: uploads
|
||||
UPLOAD_FOLDER=uploads
|
||||
|
||||
# SESSION_TIMEOUT_MINUTES:
|
||||
# Czas bezczynności użytkownika (w minutach), po którym sesja wygasa
|
||||
# Domyślnie: 10080 (7 dni)
|
||||
SESSION_TIMEOUT_MINUTES=10080
|
||||
|
||||
# AUTH_COOKIE_MAX_AGE:
|
||||
# Czas życia ciasteczka autoryzacyjnego (w sekundach)
|
||||
# Domyślnie: 86400 (1 dzień)
|
||||
AUTH_COOKIE_MAX_AGE=86400
|
||||
|
||||
# AUTHORIZED_COOKIE_VALUE:
|
||||
# Wartość ciasteczka uprawniającego do dostępu (np. do zasobów zabezpieczonych)
|
||||
# Powinna być trudna do przewidzenia
|
||||
# Chodzi to o zabezpieczenie strony "hasłęm głównym czyli endpointem /system-auth"
|
||||
AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash
|
||||
|
||||
# czas zycia cookie
|
||||
AUTH_COOKIE_MAX_AGE=86400
|
||||
# SESSION_COOKIE_SECURE:
|
||||
# Określa, czy ciasteczko sesyjne (Flask session) ma mieć ustawiony atrybut "Secure".
|
||||
# Wymusza, by przeglądarka przesyłała je tylko przez HTTPS.
|
||||
# W środowisku deweloperskim (HTTP) ustaw na 0, by uniknąć błędu "secure cookie over insecure connection".
|
||||
# Zalecane: 1 w produkcji (HTTPS), 0 w dev.
|
||||
SESSION_COOKIE_SECURE=0
|
||||
|
||||
# BCRYPT_PEPPER:
|
||||
# Dodatkowy „sekretny klucz” (pepper) dodawany do hasła przed zahashowaniem
|
||||
# Zwiększa bezpieczeństwo przechowywanych haseł
|
||||
BCRYPT_PEPPER=sekretnyKluczbcrypt
|
||||
|
||||
# HEALTHCHECK_TOKEN:
|
||||
# Token wykorzystywany do sprawdzania stanu aplikacji (np. w Docker Compose)
|
||||
# Domyślnie: alamapsaikota123
|
||||
HEALTHCHECK_TOKEN=alamapsaikota123
|
||||
|
||||
# Rodzaj bazy: sqlite, pgsql, mysql
|
||||
# Mozliwe wartosci: sqlite / pgsql / mysql
|
||||
DB_ENGINE=sqlite
|
||||
|
||||
# --- Konfiguracja dla sqlite ---
|
||||
# Plik bazy bedzie utworzony automatycznie w katalogu ./instance
|
||||
# Pozostale zmienne sa ignorowane przy DB_ENGINE=sqlite
|
||||
|
||||
# --- Konfiguracja dla pgsql ---
|
||||
# Ustaw DB_ENGINE=pgsql
|
||||
# Domyslny port PostgreSQL to 5432
|
||||
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
|
||||
|
||||
# --- Konfiguracja dla mysql ---
|
||||
# Ustaw DB_ENGINE=mysql
|
||||
# Domyslny port MySQL to 3306
|
||||
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
|
||||
|
||||
# Wspolne zmienne (dla pgsql, mysql)
|
||||
# DB_HOST = pgsql lub mysql zgodnie z deployem (profil w docker-compose.yml)
|
||||
|
||||
DB_HOST=pgsql
|
||||
DB_PORT=5432
|
||||
DB_NAME=myapp
|
||||
DB_USER=user
|
||||
DB_PASSWORD=pass
|
||||
|
||||
# ========================
|
||||
# Nagłówki bezpieczeństwa
|
||||
# ========================
|
||||
|
||||
# ENABLE_HSTS:
|
||||
# Wymusza HTTPS poprzez ustawienie nagłówka Strict-Transport-Security.
|
||||
# Zalecane (1) jeśli aplikacja działa za HTTPS. Ustaw 0, jeśli korzystasz z HTTP lokalnie.
|
||||
ENABLE_HSTS=1
|
||||
|
||||
# ENABLE_XFO:
|
||||
# Ustawia nagłówek X-Frame-Options: DENY, który blokuje osadzanie strony w <iframe>.
|
||||
# Chroni przed atakami typu clickjacking. Ustaw 0, jeśli celowo korzystasz z osadzania.
|
||||
ENABLE_XFO=1
|
||||
|
||||
# ENABLE_XCTO:
|
||||
# Ustawia nagłówek X-Content-Type-Options: nosniff, który zapobiega sniffowaniu MIME przez przeglądarkę.
|
||||
# Chroni przed błędną interpretacją typów plików (np. skrypt JS jako obraz). Zalecane: 1.
|
||||
ENABLE_XCTO=1
|
||||
|
||||
# ENABLE_CSP:
|
||||
# Ustawia podstawową politykę Content-Security-Policy (CSP), która ogranicza wczytywanie zasobów tylko z własnej domeny.
|
||||
# Zalecane: 1. Ustaw 0, jeśli używasz zewnętrznych skryptów lub masz problemy z WebSocketami (w CSP: connect-src 'self').
|
||||
ENABLE_CSP=1
|
||||
|
||||
# REFERRER_POLICY:
|
||||
# Ustawia nagłówek Referrer-Policy, który kontroluje, ile informacji o źródle (refererze)
|
||||
# jest przekazywane podczas nawigacji lub zapytań sieciowych.
|
||||
# Domyślnie: strict-origin-when-cross-origin — pełny URL tylko w obrębie tej samej domeny,
|
||||
# a przy przejściach między domenami tylko origin (np. https://example.com).
|
||||
# Zalecane ustawienie dla dobrej równowagi między prywatnością a funkcjonalnością.
|
||||
# Inne możliwe wartości: no-referrer, same-origin, origin, strict-origin, unsafe-url itd.
|
||||
REFERRER_POLICY="strict-origin-when-cross-origin"
|
||||
|
||||
# DEBUG_MODE:
|
||||
# Czy uruchomić aplikację w trybie debugowania (z konsolą błędów i autoreloaderem)
|
||||
# Domyślnie: 1
|
||||
DEBUG_MODE=1
|
||||
|
||||
# DISABLE_ROBOTS:
|
||||
# Czy zablokować indeksowanie przez roboty (serwuje robots.txt z Disallow: /)
|
||||
# Domyślnie: 0
|
||||
DISABLE_ROBOTS=0
|
||||
|
||||
# ========================
|
||||
# Nagłówki cache
|
||||
# ========================
|
||||
|
||||
# JS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla plików JS (/static/js/)
|
||||
# Domyślnie: "no-cache"
|
||||
JS_CACHE_CONTROL="no-cache"
|
||||
|
||||
# CSS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla plików CSS (/static/css/)
|
||||
# Domyślnie: "no-cache"
|
||||
CSS_CACHE_CONTROL="no-cache"
|
||||
|
||||
# LIB_JS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla bibliotek JS (/static/lib/js/)
|
||||
# Domyślnie: "max-age=86400"
|
||||
LIB_JS_CACHE_CONTROL="max-age=86400"
|
||||
|
||||
# LIB_CSS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla bibliotek CSS (/static/lib/css/)
|
||||
# Domyślnie: "max-age=86400"
|
||||
LIB_CSS_CACHE_CONTROL="max-age=3600"
|
||||
|
||||
# UPLOADS_CACHE_CONTROL:
|
||||
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
|
||||
# Domyślnie: "max-age=2592000, immutable"
|
||||
UPLOADS_CACHE_CONTROL="max-age=3600, immutable"
|
||||
|
||||
# DEFAULT_CATEGORIES:
|
||||
# Lista domyślnych kategorii tworzonych automatycznie przy starcie aplikacji,
|
||||
# jeśli nie istnieją w bazie danych.
|
||||
# Podaj w formacie CSV (oddzielone przecinkami) – kolejność zostanie zachowana.
|
||||
# Możesz dodać własne kategorie
|
||||
# UWAGA: Wielkość liter w nazwach jest zachowywana, ale porównywanie odbywa się
|
||||
# bez rozróżniania wielkości liter (case-insensitive).
|
||||
# Domyślnie: poniższa lista
|
||||
DEFAULT_CATEGORIES="Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"
|
||||
|
||||
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.py text working-tree-encoding=UTF-8
|
||||
*.env.example text working-tree-encoding=UTF-8
|
||||
.env text working-tree-encoding=UTF-8
|
||||
9
.gitignore
vendored
@@ -3,6 +3,11 @@ venv
|
||||
env
|
||||
*.db
|
||||
__pycache__
|
||||
instance/
|
||||
uploads/
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
db/mysql/*
|
||||
db/pgsql/*
|
||||
db/shopping.db
|
||||
*.swp
|
||||
version.txt
|
||||
deploy/varnish/default.vcl
|
||||
33
API_OPIS.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
API aplikacji Lista Zakupów
|
||||
|
||||
Autoryzacja:
|
||||
- Authorization: Bearer TWOJ_TOKEN
|
||||
- albo X-API-Token: TWOJ_TOKEN
|
||||
|
||||
Token ma jednocześnie dwa ograniczenia:
|
||||
1. zakresy (scopes), np. expenses:read, lists:read, templates:read
|
||||
2. dozwolone endpointy
|
||||
|
||||
Dostępne endpointy:
|
||||
- GET /api/ping
|
||||
Test poprawności tokenu.
|
||||
|
||||
- GET /api/expenses/latest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID&limit=50
|
||||
Zwraca ostatnie wydatki wraz z metadanymi listy i właściciela.
|
||||
|
||||
- GET /api/expenses/summary?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID
|
||||
Zwraca sumę wydatków, liczbę rekordów i agregację po listach.
|
||||
|
||||
- GET /api/lists?owner_id=ID&limit=50
|
||||
Zwraca listy z podstawowymi metadanymi.
|
||||
|
||||
- GET /api/lists/<id>/expenses?limit=50
|
||||
Zwraca wydatki przypisane do konkretnej listy.
|
||||
|
||||
- GET /api/templates?owner_id=ID
|
||||
Zwraca aktywne szablony.
|
||||
|
||||
Uwagi:
|
||||
- limit odpowiedzi jest przycinany do max_limit ustawionego na tokenie
|
||||
- daty przekazuj w formacie YYYY-MM-DD
|
||||
- endpoint musi być zaznaczony na tokenie, samo posiadanie zakresu nie wystarczy
|
||||
30
CLI_OPIS.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
Komendy CLI
|
||||
===========
|
||||
|
||||
Admini
|
||||
-------
|
||||
flask admins list
|
||||
flask admins create <username> <password> [--admin/--user]
|
||||
flask admins promote <username|id>
|
||||
flask admins demote <username|id>
|
||||
flask admins set-password <username|id> <password>
|
||||
|
||||
Listy
|
||||
-----
|
||||
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30"
|
||||
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --owner admin
|
||||
flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --title "Zakupy piatkowe"
|
||||
|
||||
Zasady dzialania
|
||||
----------------
|
||||
- copy-schedule tworzy nowa liste na podstawie istniejacej
|
||||
- kopiuje pozycje i przypisane kategorie
|
||||
- ustawia nowy created_at na wartosc z parametru --when
|
||||
- gdy lista byla tymczasowa i miala expires_at, termin wygasniecia jest przesuwany o ten sam odstep czasu
|
||||
- wydatki i paragony nie sa kopiowane
|
||||
|
||||
|
||||
SZABLONY I HISTORIA:
|
||||
- Historia zmian listy jest widoczna w widoku listy właściciela.
|
||||
- Szablon można utworzyć z panelu admina lub z poziomu listy właściciela.
|
||||
- Admin może szybko utworzyć listę z szablonu i zduplikować listę jednym kliknięciem.
|
||||
20
Dockerfile
@@ -1,9 +1,19 @@
|
||||
# Używamy lekkiego obrazu Pythona
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Ustawiamy katalog roboczy
|
||||
FROM python:3.14-trixie
|
||||
#FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Zależności systemowe do OCR, obrazów, tesseract i języka PL
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-pol \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxrender1 \
|
||||
libxext6 \
|
||||
poppler-utils \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Kopiujemy wymagania
|
||||
COPY requirements.txt requirements.txt
|
||||
|
||||
@@ -18,7 +28,7 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Otwieramy port
|
||||
EXPOSE 8000
|
||||
#EXPOSE 8000
|
||||
|
||||
# Ustawiamy entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
105
README.md
@@ -1,59 +1,94 @@
|
||||
# Live Lista Zakupów
|
||||
# Aplikacja List Zakupów
|
||||
|
||||
Aplikacja webowa do współdzielonych list zakupów z obsługą wielu użytkowników, trybem współpracy w czasie rzeczywistym, panelami administracyjnymi oraz możliwością załączania paragonów.
|
||||
Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkowników, OCR paragonów, statystykami i trybem współdzielenia.
|
||||
|
||||
## Funkcje
|
||||
## Główne funkcje
|
||||
|
||||
- Tworzenie, edycja i archiwizacja list zakupów
|
||||
- Dodawanie, edycja, usuwanie produktów i oznaczanie ich jako kupione
|
||||
- Udostępnianie list przez link (token)
|
||||
- Wgrywanie zdjęć paragonów do listy zakupów
|
||||
- Wyszukiwarka produktów i podpowiedzi
|
||||
- Komentarze do produktów
|
||||
- Panel administracyjny (zarządzanie użytkownikami, listami, paragonami)
|
||||
- Obsługa w czasie rzeczywistym (Socket.IO)
|
||||
- Logowanie i autoryzacja użytkowników
|
||||
- Systemowe hasło dostępu do aplikacji
|
||||
- Logowanie i zarządzanie użytkownikami (admin/user)
|
||||
- Tworzenie list zakupów z pozycjami i ilością
|
||||
- Wgrywanie paragonów (podstawowa obsługa OCR)
|
||||
- Archiwizacja i udostępnianie list (publiczne/prywatne)
|
||||
- Statystyki wydatków z podziałem na okresy, statystyki dla użytkowników
|
||||
- Panel administracyjny (statystyki, produkty, paragony, zarządzanie, użytkowmicy)
|
||||
- Tokeny API administratora i endpoint do pobierania ostatnich wydatków
|
||||
- Ujednolicony UI formularzy, tabel i przycisków oraz drobne usprawnienia UX
|
||||
|
||||
## Wymagania
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Python 3.9+
|
||||
- Docker (opcjonalnie dla produkcji)
|
||||
|
||||
## Sposób uruchomienia z Docker Compose
|
||||
## Instalacja lokalna (deweloperska)
|
||||
|
||||
1. **Przygotuj plik `.env` w katalogu głównym projektu** (przykład):
|
||||
1. Sklonuj repozytorium:
|
||||
|
||||
`APP_PORT=8000`
|
||||
```bash
|
||||
git https://git.linuxiarz.pl/gru/lista_zakupowa_live.git
|
||||
cd lista_zakupowa_live
|
||||
```
|
||||
|
||||
`SECRET_KEY=twoj_super_tajny_klucz`
|
||||
2. Utwórz i uzupełnij plik `.env` (zobacz `.env example`).
|
||||
|
||||
`SYSTEM_PASSWORD=haslo_do_aplikacji`
|
||||
3. Utwórz środowisko i zainstaluj zależności:
|
||||
|
||||
`DEFAULT_ADMIN_USERNAME=admin`
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`DEFAULT_ADMIN_PASSWORD=admin123`
|
||||
4. Uruchom aplikację:
|
||||
|
||||
2. **Uruchom aplikację:**
|
||||
```bash
|
||||
flask --app app.py run
|
||||
```
|
||||
|
||||
Domyślnie aplikacja będzie dostępna pod adresem:
|
||||
**http://localhost:8000**
|
||||
## Deploy z Docker Compose - stack (zalecana)
|
||||
|
||||
3. **Pierwsze logowanie:**
|
||||
- Po wejściu na stronę zostaniesz poproszony o podanie hasła systemowego (`SYSTEM_PASSWORD`).
|
||||
- Przy pierwszym uruchomieniu zostanie automatycznie utworzone konto administratora na podstawie zmiennych `DEFAULT_ADMIN_USERNAME` i `DEFAULT_ADMIN_PASSWORD`.
|
||||
1. Skonfiguruj `.env`.
|
||||
|
||||
2.1 Uruchom: (pgsql)
|
||||
|
||||
```bash
|
||||
bash deploy_docker.sh pgsql
|
||||
```
|
||||
|
||||
2.2 Uruchom: (mysql)
|
||||
|
||||
```bash
|
||||
bash deploy_docker.sh mysql
|
||||
```
|
||||
|
||||
2.3 Uruchom: (sqlite)
|
||||
|
||||
```bash
|
||||
bash deploy_docker.sh sqlite
|
||||
```
|
||||
|
||||
Aplikacja będzie dostępna pod `http://localhost:8000`.
|
||||
|
||||
## Domyślne dane logowania
|
||||
|
||||
- **Login administratora:** `admin` (lub wartość z `DEFAULT_ADMIN_USERNAME`)
|
||||
- **Hasło administratora:** `admin123` (lub wartość z `DEFAULT_ADMIN_PASSWORD`)
|
||||
- Główne hasło systemowe: `admin`
|
||||
- Admin: `admin` / `admin123`
|
||||
|
||||
4. **Aby uruchomić aplikację w Dockerze, wykonaj następujące kroki:**
|
||||
## Konfiguracja bazy danych
|
||||
|
||||
* Przygotuj plik .env w katalogu projektu z wymaganymi zmiennymi środowiskowymi
|
||||
* Uruchom aplikację poleceniem:
|
||||
docker compose up --build
|
||||
Obsługiwane silniki: `sqlite`, `pgsql`, `mysql`.
|
||||
|
||||
---
|
||||
Ustaw `DB_ENGINE` oraz odpowiednie zmienne w `.env`:
|
||||
|
||||
Przykład dla PostgreSQL:
|
||||
|
||||
```env
|
||||
DB_ENGINE=pgsql
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_NAME=myapp
|
||||
DB_USER=user
|
||||
DB_PASSWORD=pass
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
Opis komend administracyjnych znajduje sie w pliku `CLI_OPIS.txt`.
|
||||
|
||||
270
_tools/add_products.py
Normal file
@@ -0,0 +1,270 @@
|
||||
import urllib.request
|
||||
import json
|
||||
from app import db, SuggestedProduct, app
|
||||
|
||||
CATEGORIES = {
|
||||
"Przyprawa": [
|
||||
"przyprawa",
|
||||
"pieprz",
|
||||
"sól",
|
||||
"bazylia",
|
||||
"oregano",
|
||||
"papryka",
|
||||
"majeranek",
|
||||
"czosnek",
|
||||
"tymianek",
|
||||
"rozmaryn",
|
||||
"kolendra",
|
||||
"curry",
|
||||
"imbir",
|
||||
"goździki",
|
||||
"chili",
|
||||
"koper",
|
||||
"kminek",
|
||||
"liść laurowy",
|
||||
"ziele angielskie",
|
||||
"kurkuma",
|
||||
"musztarda",
|
||||
"chrzan",
|
||||
],
|
||||
"Mięso": [
|
||||
"kurczak",
|
||||
"piersi z kurczaka",
|
||||
"udka z kurczaka",
|
||||
"wołowina",
|
||||
"mielona wołowina",
|
||||
"wieprzowina",
|
||||
"schab",
|
||||
"łopatka",
|
||||
"szynka",
|
||||
"boczek",
|
||||
"indyk",
|
||||
"filet z indyka",
|
||||
"gulasz",
|
||||
"pasztet",
|
||||
"karkówka",
|
||||
"żeberka",
|
||||
"kiełbasa",
|
||||
"parówki",
|
||||
"salami",
|
||||
"kabanos",
|
||||
],
|
||||
"Ryba i owoce morza": [
|
||||
"łosoś",
|
||||
"dorsz",
|
||||
"mintaj",
|
||||
"makrela",
|
||||
"pstrąg",
|
||||
"karp",
|
||||
"śledź",
|
||||
"tuńczyk",
|
||||
"morszczuk",
|
||||
"sardynka",
|
||||
"szproty",
|
||||
"anchois",
|
||||
"tilapia",
|
||||
"sandacz",
|
||||
"halibut",
|
||||
"sum",
|
||||
"flądra",
|
||||
"ostrobok",
|
||||
"paluszki rybne",
|
||||
"konserwa rybna",
|
||||
],
|
||||
"Nabiał": [
|
||||
"mleko",
|
||||
"jogurt",
|
||||
"ser żółty",
|
||||
"ser biały",
|
||||
"twaróg",
|
||||
"śmietana",
|
||||
"masło",
|
||||
"kefir",
|
||||
"maślanka",
|
||||
"serek wiejski",
|
||||
"serek topiony",
|
||||
"mozzarella",
|
||||
"feta",
|
||||
"parmezan",
|
||||
"gouda",
|
||||
"emmental",
|
||||
"ser pleśniowy",
|
||||
"ser homogenizowany",
|
||||
"serek mascarpone",
|
||||
"ser ricotta",
|
||||
],
|
||||
"Warzywo": [
|
||||
"pomidor",
|
||||
"ogórek",
|
||||
"marchew",
|
||||
"cebula",
|
||||
"sałata",
|
||||
"papryka",
|
||||
"ziemniak",
|
||||
"kapusta",
|
||||
"brokuł",
|
||||
"kalafior",
|
||||
"cukinia",
|
||||
"bakłażan",
|
||||
"szpinak",
|
||||
"rukola",
|
||||
"seler",
|
||||
"por",
|
||||
"burak",
|
||||
"dynia",
|
||||
"rzodkiewka",
|
||||
"fasola",
|
||||
],
|
||||
"Owoc": [
|
||||
"jabłko",
|
||||
"banan",
|
||||
"gruszka",
|
||||
"truskawka",
|
||||
"winogrono",
|
||||
"malina",
|
||||
"borówka",
|
||||
"czereśnia",
|
||||
"wiśnia",
|
||||
"brzoskwinia",
|
||||
"nektaryna",
|
||||
"śliwka",
|
||||
"ananas",
|
||||
"mango",
|
||||
"kiwi",
|
||||
"cytryna",
|
||||
"limonka",
|
||||
"pomarańcza",
|
||||
"mandarynka",
|
||||
"grejpfrut",
|
||||
],
|
||||
"Pieczywo i zboża": [
|
||||
"chleb",
|
||||
"bułka",
|
||||
"bagietka",
|
||||
"kajzerka",
|
||||
"pumpernikiel",
|
||||
"chleb razowy",
|
||||
"chleb żytni",
|
||||
"tost",
|
||||
"grahamka",
|
||||
"croissant",
|
||||
"tortilla",
|
||||
"pizza",
|
||||
"pierogi",
|
||||
"ryż",
|
||||
"makaron",
|
||||
"kasza jaglana",
|
||||
"kasza gryczana",
|
||||
"owsianka",
|
||||
"płatki kukurydziane",
|
||||
"musli",
|
||||
],
|
||||
"Słodycze i przekąski": [
|
||||
"czekolada",
|
||||
"baton",
|
||||
"ciastko",
|
||||
"wafel",
|
||||
"lody",
|
||||
"cukierek",
|
||||
"żelki",
|
||||
"herbatnik",
|
||||
"paluszki",
|
||||
"chipsy",
|
||||
"orzeszki",
|
||||
"popcorn",
|
||||
"krakersy",
|
||||
"ciasto",
|
||||
"muffin",
|
||||
"pączek",
|
||||
"drożdżówka",
|
||||
"babeczka",
|
||||
"piernik",
|
||||
"beza",
|
||||
],
|
||||
"Napoje": [
|
||||
"woda",
|
||||
"sok jabłkowy",
|
||||
"sok pomarańczowy",
|
||||
"sok multiwitamina",
|
||||
"cola",
|
||||
"pepsi",
|
||||
"napój gazowany",
|
||||
"kawa",
|
||||
"herbata",
|
||||
"piwo",
|
||||
"wino czerwone",
|
||||
"wino białe",
|
||||
"tonik",
|
||||
"lemoniada",
|
||||
"napój izotoniczny",
|
||||
"kompot",
|
||||
"napój mleczny",
|
||||
"maślanka pitna",
|
||||
"koktajl owocowy",
|
||||
"nektar",
|
||||
],
|
||||
"Tłuszcze i oleje": [
|
||||
"oliwa",
|
||||
"olej rzepakowy",
|
||||
"olej słonecznikowy",
|
||||
"masło klarowane",
|
||||
"margaryna",
|
||||
"smalec",
|
||||
"masło orzechowe",
|
||||
"tłuszcz kokosowy",
|
||||
"olej lniany",
|
||||
"olej z pestek winogron",
|
||||
"olej sezamowy",
|
||||
"olej ryżowy",
|
||||
"olej z awokado",
|
||||
"olej kukurydziany",
|
||||
"olej arachidowy",
|
||||
"olej palmowy",
|
||||
"olej konopny",
|
||||
"olej sojowy",
|
||||
"olej dyniowy",
|
||||
"olej z orzechów włoskich",
|
||||
],
|
||||
"Dania gotowe": [
|
||||
"pizza",
|
||||
"hamburger",
|
||||
"hot dog",
|
||||
"zupa",
|
||||
"gulasz",
|
||||
"pierogi ruskie",
|
||||
"pierogi z mięsem",
|
||||
"lasagne",
|
||||
"sałatka warzywna",
|
||||
"kanapka",
|
||||
"wrap",
|
||||
"tortilla",
|
||||
"zapiekanka",
|
||||
"sushi",
|
||||
"falafel",
|
||||
"kebab",
|
||||
"pyzy",
|
||||
"kluski śląskie",
|
||||
"kotlet schabowy",
|
||||
"gołąbki",
|
||||
],
|
||||
}
|
||||
|
||||
produkty = []
|
||||
|
||||
for category, names in CATEGORIES.items():
|
||||
for name in names:
|
||||
produkty.append((category, name.lower().strip()))
|
||||
|
||||
print(f"Przygotowano {len(produkty)} produktów do dodania.")
|
||||
|
||||
with app.app_context():
|
||||
dodane = 0
|
||||
for category, name in produkty:
|
||||
full_name = f"{category}: {name}"
|
||||
if not SuggestedProduct.query.filter_by(name=full_name).first():
|
||||
prod = SuggestedProduct(name=full_name)
|
||||
db.session.add(prod)
|
||||
dodane += 1
|
||||
db.session.commit()
|
||||
|
||||
print(f"Dodano {dodane} produktów do bazy.")
|
||||
47
_tools/add_receipt_to_list.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from app import db, app, Receipt
|
||||
|
||||
|
||||
def extract_list_id(filename):
|
||||
if filename.startswith("list_"):
|
||||
parts = filename.split("_", 2)
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
return int(parts[1])
|
||||
return None
|
||||
|
||||
|
||||
def migrate_missing_receipts():
|
||||
with app.app_context():
|
||||
folder = app.config["UPLOAD_FOLDER"]
|
||||
files = os.listdir(folder)
|
||||
added = 0
|
||||
skipped = 0
|
||||
|
||||
for file in files:
|
||||
if not file.endswith(".webp"):
|
||||
continue
|
||||
|
||||
list_id = extract_list_id(file)
|
||||
if list_id is None:
|
||||
print(f"Pominięto (brak list_id): {file}")
|
||||
continue
|
||||
|
||||
exists = Receipt.query.filter_by(list_id=list_id, filename=file).first()
|
||||
if exists:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
new_receipt = Receipt(
|
||||
list_id=list_id, filename=file, uploaded_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(new_receipt)
|
||||
added += 1
|
||||
print(f"📄 {file} dodany do Receipt (list_id={list_id})")
|
||||
|
||||
db.session.commit()
|
||||
print(f"\n✅ Dodano: {added}, pominięto (już były): {skipped}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_missing_receipts()
|
||||
38
_tools/db/migrate.txt
Normal file
@@ -0,0 +1,38 @@
|
||||
python3 -m venv venv_migrate
|
||||
source venv_migrate/bin/activate
|
||||
pip install sqlalchemy psycopg2-binary dotenv
|
||||
docker compose --profile pgsql up -d --build
|
||||
PYTHONPATH=. python3 _tools/db/migrate_sqlite_to_pgsql.py
|
||||
rm -rf venv_migrate
|
||||
|
||||
# reset wszystkich sekwencji w pgsql
|
||||
docker exec -it pgsql-db psql -U lista -d lista
|
||||
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT
|
||||
c.relname AS seq_name,
|
||||
t.relname AS table_name,
|
||||
a.attname AS column_name
|
||||
FROM
|
||||
pg_class c
|
||||
JOIN
|
||||
pg_depend d ON d.objid = c.oid
|
||||
JOIN
|
||||
pg_class t ON d.refobjid = t.oid
|
||||
JOIN
|
||||
pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
|
||||
WHERE
|
||||
c.relkind = 'S'
|
||||
AND d.deptype = 'a'
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I), 1), true)',
|
||||
r.seq_name, r.column_name, r.table_name
|
||||
);
|
||||
END LOOP;
|
||||
END$$;
|
||||
61
_tools/db/migrate_sqlite_to_pgsql.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from sqlalchemy import create_engine, MetaData
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from config import Config
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Źródło: SQLite
|
||||
sqlite_engine = create_engine("sqlite:///instance/shopping.db")
|
||||
sqlite_meta = MetaData()
|
||||
sqlite_meta.reflect(bind=sqlite_engine)
|
||||
|
||||
# Cel: PostgreSQL
|
||||
pg_engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
|
||||
pg_meta = MetaData()
|
||||
pg_meta.reflect(bind=pg_engine)
|
||||
|
||||
# Sesje
|
||||
SQLiteSession = sessionmaker(bind=sqlite_engine)
|
||||
PGSession = sessionmaker(bind=pg_engine)
|
||||
|
||||
sqlite_session = SQLiteSession()
|
||||
pg_session = PGSession()
|
||||
|
||||
def migrate_table(table_name):
|
||||
print("➡️ Używana baza docelowa:", Config.SQLALCHEMY_DATABASE_URI)
|
||||
print(f"\n➡️ Migruję tabelę: {table_name}")
|
||||
source_table = sqlite_meta.tables.get(table_name)
|
||||
target_table = pg_meta.tables.get(table_name)
|
||||
|
||||
if source_table is None or target_table is None:
|
||||
print(f"⚠️ Pominięto: {table_name} (brak w jednej z baz)")
|
||||
return
|
||||
|
||||
rows = sqlite_session.execute(source_table.select()).fetchall()
|
||||
if not rows:
|
||||
print("ℹ️ Brak danych do migracji.")
|
||||
return
|
||||
|
||||
insert_data = [dict(row._mapping) for row in rows]
|
||||
|
||||
try:
|
||||
with pg_engine.begin() as conn:
|
||||
conn.execute(target_table.delete())
|
||||
conn.execute(target_table.insert(), insert_data)
|
||||
print(f"✅ Przeniesiono: {len(rows)} rekordów")
|
||||
except Exception as e:
|
||||
print(f"❌ Błąd przy migracji {table_name}: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
tables = ["user", "shopping_list", "item", "expense", "receipt", "suggested_product"]
|
||||
for table in tables:
|
||||
migrate_table(table)
|
||||
print("\n🎉 Migracja zakończona pomyślnie.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
83
_tools/migrate_to_webp.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
from app import db, app, Receipt
|
||||
|
||||
ALLOWED_EXTS = ("jpg", "jpeg", "png", "gif", "heic")
|
||||
UPLOAD_FOLDER = None
|
||||
|
||||
|
||||
def convert_to_webp(input_path, output_path):
|
||||
try:
|
||||
image = Image.open(input_path).convert("RGB")
|
||||
image.save(output_path, "WEBP", quality=85)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Błąd konwersji {input_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def extract_list_id(filename):
|
||||
if filename.startswith("list_"):
|
||||
parts = filename.split("_", 2)
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
return int(parts[1])
|
||||
return None
|
||||
|
||||
|
||||
def migrate():
|
||||
global UPLOAD_FOLDER
|
||||
with app.app_context():
|
||||
UPLOAD_FOLDER = app.config["UPLOAD_FOLDER"]
|
||||
files = os.listdir(UPLOAD_FOLDER)
|
||||
created = 0
|
||||
skipped = 0
|
||||
existing = 0
|
||||
|
||||
for file in files:
|
||||
ext = file.rsplit(".", 1)[-1].lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
continue
|
||||
|
||||
list_id = extract_list_id(file)
|
||||
if list_id is None:
|
||||
print(f"Pominięto (brak list_id): {file}")
|
||||
continue
|
||||
|
||||
src_path = os.path.join(UPLOAD_FOLDER, file)
|
||||
base = os.path.splitext(file)[0]
|
||||
webp_filename = base + ".webp"
|
||||
dst_path = os.path.join(UPLOAD_FOLDER, webp_filename)
|
||||
|
||||
if os.path.exists(dst_path):
|
||||
print(f"Pominięto (webp istnieje): {webp_filename}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if convert_to_webp(src_path, dst_path):
|
||||
os.remove(src_path)
|
||||
r = Receipt.query.filter_by(
|
||||
list_id=list_id, filename=webp_filename
|
||||
).first()
|
||||
if r:
|
||||
print(f"Już istnieje w Receipt: {webp_filename}")
|
||||
existing += 1
|
||||
continue
|
||||
|
||||
new_receipt = Receipt(
|
||||
list_id=list_id,
|
||||
filename=webp_filename,
|
||||
uploaded_at=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(new_receipt)
|
||||
created += 1
|
||||
print(f"{file} → {webp_filename} + zapis do Receipt")
|
||||
|
||||
db.session.commit()
|
||||
print(f"\nNowe wpisy: {created}")
|
||||
print(f"Pominięte (webp istniało): {skipped}")
|
||||
print(f"Duplikaty w bazie: {existing}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
44
_tools/update_missing_image_data.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from app import app, db, Receipt
|
||||
|
||||
|
||||
def update_missing_receipt_fields():
|
||||
with app.app_context():
|
||||
folder = app.config["UPLOAD_FOLDER"]
|
||||
updated = 0
|
||||
|
||||
receipts = Receipt.query.filter(
|
||||
(Receipt.filesize == None)
|
||||
| (Receipt.filesize == 0)
|
||||
| (Receipt.uploaded_at == None)
|
||||
).all()
|
||||
|
||||
for r in receipts:
|
||||
path = os.path.join(folder, r.filename)
|
||||
if not os.path.exists(path):
|
||||
print(f"Brak pliku: {r.filename}")
|
||||
continue
|
||||
|
||||
changed = False
|
||||
|
||||
if not r.filesize:
|
||||
r.filesize = os.path.getsize(path)
|
||||
changed = True
|
||||
print(f"{r.filename} → filesize: {r.filesize} B")
|
||||
|
||||
if not r.uploaded_at:
|
||||
timestamp = os.path.getmtime(path)
|
||||
r.uploaded_at = datetime.fromtimestamp(timestamp)
|
||||
changed = True
|
||||
print(f"{r.filename} → uploaded_at: {r.uploaded_at}")
|
||||
|
||||
if changed:
|
||||
updated += 1
|
||||
|
||||
db.session.commit()
|
||||
print(f"\nZaktualizowano {updated} rekordów.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_missing_receipt_fields()
|
||||
23
_tools/wait_for_db.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import sys
|
||||
|
||||
db_engine = os.environ.get("DB_ENGINE", "mysql").lower()
|
||||
|
||||
if db_engine == "sqlite":
|
||||
print("SQLite - koncze oczekiwanie na baze..")
|
||||
sys.exit(0)
|
||||
|
||||
host = os.environ.get("DB_HOST", "mysql")
|
||||
port = int(os.environ.get("DB_PORT", 3306))
|
||||
print(f"Czekam na bazę danych {host}:{port}...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=5):
|
||||
print("Baza danych jest dostępna.")
|
||||
break
|
||||
except OSError:
|
||||
print("Baza jeszcze nie odpowiada, czekam...")
|
||||
time.sleep(2)
|
||||
@@ -1,87 +0,0 @@
|
||||
import urllib.request
|
||||
import json
|
||||
from app import db, SuggestedProduct, app
|
||||
|
||||
CATEGORIES = {
|
||||
"Przyprawa": [
|
||||
"przyprawa", "pieprz", "sól", "bazylia", "oregano", "papryka", "majeranek", "czosnek",
|
||||
"tymianek", "rozmaryn", "kolendra", "curry", "imbir", "goździki", "chili", "koper",
|
||||
"kminek", "liść laurowy", "ziele angielskie", "kurkuma", "musztarda", "chrzan"
|
||||
],
|
||||
"Mięso": [
|
||||
"kurczak", "piersi z kurczaka", "udka z kurczaka", "wołowina", "mielona wołowina",
|
||||
"wieprzowina", "schab", "łopatka", "szynka", "boczek", "indyk", "filet z indyka",
|
||||
"gulasz", "pasztet", "karkówka", "żeberka", "kiełbasa", "parówki", "salami", "kabanos"
|
||||
],
|
||||
"Ryba i owoce morza": [
|
||||
"łosoś", "dorsz", "mintaj", "makrela", "pstrąg", "karp", "śledź", "tuńczyk",
|
||||
"morszczuk", "sardynka", "szproty", "anchois", "tilapia", "sandacz", "halibut",
|
||||
"sum", "flądra", "ostrobok", "paluszki rybne", "konserwa rybna"
|
||||
],
|
||||
"Nabiał": [
|
||||
"mleko", "jogurt", "ser żółty", "ser biały", "twaróg", "śmietana", "masło",
|
||||
"kefir", "maślanka", "serek wiejski", "serek topiony", "mozzarella", "feta",
|
||||
"parmezan", "gouda", "emmental", "ser pleśniowy", "ser homogenizowany",
|
||||
"serek mascarpone", "ser ricotta"
|
||||
],
|
||||
"Warzywo": [
|
||||
"pomidor", "ogórek", "marchew", "cebula", "sałata", "papryka", "ziemniak",
|
||||
"kapusta", "brokuł", "kalafior", "cukinia", "bakłażan", "szpinak", "rukola",
|
||||
"seler", "por", "burak", "dynia", "rzodkiewka", "fasola"
|
||||
],
|
||||
"Owoc": [
|
||||
"jabłko", "banan", "gruszka", "truskawka", "winogrono", "malina", "borówka",
|
||||
"czereśnia", "wiśnia", "brzoskwinia", "nektaryna", "śliwka", "ananas",
|
||||
"mango", "kiwi", "cytryna", "limonka", "pomarańcza", "mandarynka", "grejpfrut"
|
||||
],
|
||||
"Pieczywo i zboża": [
|
||||
"chleb", "bułka", "bagietka", "kajzerka", "pumpernikiel", "chleb razowy",
|
||||
"chleb żytni", "tost", "grahamka", "croissant", "tortilla", "pizza",
|
||||
"pierogi", "ryż", "makaron", "kasza jaglana", "kasza gryczana", "owsianka",
|
||||
"płatki kukurydziane", "musli"
|
||||
],
|
||||
"Słodycze i przekąski": [
|
||||
"czekolada", "baton", "ciastko", "wafel", "lody", "cukierek", "żelki",
|
||||
"herbatnik", "paluszki", "chipsy", "orzeszki", "popcorn", "krakersy",
|
||||
"ciasto", "muffin", "pączek", "drożdżówka", "babeczka", "piernik", "beza"
|
||||
],
|
||||
"Napoje": [
|
||||
"woda", "sok jabłkowy", "sok pomarańczowy", "sok multiwitamina", "cola",
|
||||
"pepsi", "napój gazowany", "kawa", "herbata", "piwo", "wino czerwone",
|
||||
"wino białe", "tonik", "lemoniada", "napój izotoniczny", "kompot",
|
||||
"napój mleczny", "maślanka pitna", "koktajl owocowy", "nektar"
|
||||
],
|
||||
"Tłuszcze i oleje": [
|
||||
"oliwa", "olej rzepakowy", "olej słonecznikowy", "masło klarowane",
|
||||
"margaryna", "smalec", "masło orzechowe", "tłuszcz kokosowy",
|
||||
"olej lniany", "olej z pestek winogron", "olej sezamowy",
|
||||
"olej ryżowy", "olej z awokado", "olej kukurydziany", "olej arachidowy",
|
||||
"olej palmowy", "olej konopny", "olej sojowy", "olej dyniowy", "olej z orzechów włoskich"
|
||||
],
|
||||
"Dania gotowe": [
|
||||
"pizza", "hamburger", "hot dog", "zupa", "gulasz", "pierogi ruskie",
|
||||
"pierogi z mięsem", "lasagne", "sałatka warzywna", "kanapka",
|
||||
"wrap", "tortilla", "zapiekanka", "sushi", "falafel", "kebab",
|
||||
"pyzy", "kluski śląskie", "kotlet schabowy", "gołąbki"
|
||||
]
|
||||
}
|
||||
|
||||
produkty = []
|
||||
|
||||
for category, names in CATEGORIES.items():
|
||||
for name in names:
|
||||
produkty.append((category, name.lower().strip()))
|
||||
|
||||
print(f"Przygotowano {len(produkty)} produktów do dodania.")
|
||||
|
||||
with app.app_context():
|
||||
dodane = 0
|
||||
for category, name in produkty:
|
||||
full_name = f"{category}: {name}"
|
||||
if not SuggestedProduct.query.filter_by(name=full_name).first():
|
||||
prod = SuggestedProduct(name=full_name)
|
||||
db.session.add(prod)
|
||||
dodane += 1
|
||||
db.session.commit()
|
||||
|
||||
print(f"Dodano {dodane} produktów do bazy.")
|
||||
33
alters.txt
@@ -1,33 +0,0 @@
|
||||
# SUGEROWANE PRODUKTY
|
||||
CREATE TABLE IF NOT EXISTS suggested_product (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
# NOTATKI
|
||||
ALTER TABLE item
|
||||
ADD COLUMN note TEXT;
|
||||
|
||||
# NOWE FUNKCJE ADMINA
|
||||
ALTER TABLE shopping_list ADD COLUMN is_archived BOOLEAN DEFAULT FALSE;
|
||||
|
||||
|
||||
# FUNKCJA WYDATKOW
|
||||
CREATE TABLE expense (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER,
|
||||
amount FLOAT NOT NULL,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
receipt_filename VARCHAR(255),
|
||||
FOREIGN KEY(list_id) REFERENCES shopping_list(id)
|
||||
);
|
||||
|
||||
# FUNKCJA UKRYCIA PUBLICZNIE LISTY
|
||||
ALTER TABLE shopping_list ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT 1;
|
||||
|
||||
# ilośc produktów
|
||||
ALTER TABLE item ADD COLUMN quantity INTEGER DEFAULT 1;
|
||||
|
||||
#licznik najczesciej kupowanych reczy
|
||||
ALTER TABLE suggested_product ADD COLUMN usage_count INTEGER DEFAULT 0;
|
||||
|
||||
|
||||
89
config.py
@@ -1,12 +1,85 @@
|
||||
import os
|
||||
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'D8pceNZ8q%YR7^7F&9wAC2')
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///shopping.db')
|
||||
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax" # działa w HTTP i HTTPS
|
||||
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "D8pceNZ8q%YR7^7F&9wAC2")
|
||||
|
||||
APP_PORT = int(os.environ.get("APP_PORT", "8000") or "8000")
|
||||
|
||||
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
|
||||
if DB_ENGINE == "sqlite":
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
f"sqlite:///{os.path.join(basedir, 'db', 'shopping.db')}"
|
||||
)
|
||||
elif DB_ENGINE == "pgsql":
|
||||
SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}"
|
||||
elif DB_ENGINE == "mysql":
|
||||
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}"
|
||||
else:
|
||||
raise ValueError("Nieobsługiwany typ bazy danych.")
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SYSTEM_PASSWORD = os.environ.get('SYSTEM_PASSWORD', 'admin')
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get('DEFAULT_ADMIN_USERNAME', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123')
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
|
||||
AUTHORIZED_COOKIE_VALUE = os.environ.get('AUTHORIZED_COOKIE_VALUE', 'cookievalue')
|
||||
AUTH_COOKIE_MAX_AGE = int(os.environ.get('AUTH_COOKIE_MAX_AGE', 86400))
|
||||
SYSTEM_PASSWORD = os.environ.get("SYSTEM_PASSWORD", "admin")
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get("DEFAULT_ADMIN_USERNAME", "admin")
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin123")
|
||||
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "uploads")
|
||||
AUTHORIZED_COOKIE_VALUE = os.environ.get("AUTHORIZED_COOKIE_VALUE", "cookievalue")
|
||||
BCRYPT_PEPPER = os.environ.get("BCRYPT_PEPPER", "sekretnyKluczBcrypt")
|
||||
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
|
||||
HEALTHCHECK_TOKEN = os.environ.get("HEALTHCHECK_TOKEN", "alamapsaikota1234")
|
||||
|
||||
try:
|
||||
AUTH_COOKIE_MAX_AGE = int(
|
||||
os.environ.get("AUTH_COOKIE_MAX_AGE", "86400") or "86400"
|
||||
)
|
||||
except ValueError:
|
||||
AUTH_COOKIE_MAX_AGE = 86400
|
||||
|
||||
try:
|
||||
SESSION_TIMEOUT_MINUTES = int(
|
||||
os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080"
|
||||
)
|
||||
except ValueError:
|
||||
SESSION_TIMEOUT_MINUTES = 10080
|
||||
|
||||
ENABLE_HSTS = os.environ.get("ENABLE_HSTS", "0") == "1"
|
||||
ENABLE_XFO = os.environ.get("ENABLE_XFO", "0") == "1"
|
||||
ENABLE_XCTO = os.environ.get("ENABLE_XCTO", "0") == "1"
|
||||
ENABLE_CSP = os.environ.get("ENABLE_CSP", "0") == "1"
|
||||
ENABLE_PP = os.environ.get("ENABLE_PP", "0") == "1"
|
||||
REFERRER_POLICY = os.environ.get("REFERRER_POLICY") or None
|
||||
|
||||
DEBUG_MODE = os.environ.get("DEBUG_MODE", "1") == "1"
|
||||
DISABLE_ROBOTS = os.environ.get("DISABLE_ROBOTS", "0") == "1"
|
||||
|
||||
JS_CACHE_CONTROL = os.environ.get(
|
||||
"JS_CACHE_CONTROL", "no-cache"
|
||||
)
|
||||
CSS_CACHE_CONTROL = os.environ.get(
|
||||
"CSS_CACHE_CONTROL", "no-cache"
|
||||
)
|
||||
LIB_JS_CACHE_CONTROL = os.environ.get(
|
||||
"LIB_JS_CACHE_CONTROL", "max-age=604800"
|
||||
)
|
||||
LIB_CSS_CACHE_CONTROL = os.environ.get(
|
||||
"LIB_CSS_CACHE_CONTROL", "max-age=604800"
|
||||
)
|
||||
UPLOADS_CACHE_CONTROL = os.environ.get(
|
||||
"UPLOADS_CACHE_CONTROL", "public, max-age=2592000, immutable"
|
||||
)
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
c.strip() for c in os.environ.get(
|
||||
"DEFAULT_CATEGORIES",
|
||||
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Jedzenie poza domem,"
|
||||
"Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,"
|
||||
"Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,"
|
||||
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo,Różne,Chiny,Dom,Leki,Odzież,Samochód,Dzieci"
|
||||
).split(",") if c.strip()
|
||||
]
|
||||
|
||||
34
deploy/app/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM python:3.14-trixie
|
||||
#FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Zależności systemowe do OCR, obrazów, tesseract i języka PL
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-pol \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxrender1 \
|
||||
libxext6 \
|
||||
poppler-utils \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Kopiujemy wymagania
|
||||
COPY requirements.txt requirements.txt
|
||||
|
||||
# Instalujemy zależności
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Kopiujemy resztę aplikacji
|
||||
COPY . .
|
||||
|
||||
# Kopiujemy entrypoint i ustawiamy uprawnienia
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Otwieramy port
|
||||
#EXPOSE 8000
|
||||
|
||||
# Ustawiamy entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
263
deploy/varnish/default.vcl.template
Normal file
@@ -0,0 +1,263 @@
|
||||
vcl 4.1;
|
||||
|
||||
import vsthrottle;
|
||||
import std;
|
||||
|
||||
# ===== Backend =====
|
||||
backend app {
|
||||
.host = "app";
|
||||
.port = "${APP_PORT}";
|
||||
}
|
||||
|
||||
# ===== ACL =====
|
||||
acl purge {
|
||||
"127.0.0.1";
|
||||
"::1";
|
||||
}
|
||||
|
||||
# ===== RECV =====
|
||||
sub vcl_recv {
|
||||
# RATE LIMIT: 200 żądań / 10s, blokada 60s
|
||||
if (vsthrottle.is_denied(client.identity, 200, 10s, 60s)) {
|
||||
return (synth(429, "Too Many Requests"));
|
||||
}
|
||||
|
||||
# PURGE tylko lokalnie
|
||||
if (req.method == "PURGE") {
|
||||
if (!client.ip ~ purge) { return (synth(405, "Not allowed")); }
|
||||
return (purge);
|
||||
}
|
||||
|
||||
# omijamy cache dla healthchecków / wewnętrznych nagłówków
|
||||
if (req.url == "/healthcheck" || req.http.X-Internal-Check) { return (pass); }
|
||||
|
||||
# Specjalna obsługa WebSocket i socket.io
|
||||
if (req.http.Upgrade ~ "(?i)websocket" || req.url ~ "^/socket.io/") {
|
||||
return (pipe);
|
||||
}
|
||||
|
||||
# metody inne niż GET/HEAD bez cache
|
||||
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
|
||||
|
||||
# Żądania z Authorization nie są buforowane
|
||||
if (req.http.Authorization) { return (pass); }
|
||||
|
||||
# ---- Normalizacja Accept-Encoding (kolejność: zstd > br > gzip) ----
|
||||
if (req.http.Accept-Encoding) {
|
||||
if (req.http.Accept-Encoding ~ "zstd") {
|
||||
set req.http.Accept-Encoding = "zstd";
|
||||
} else if (req.http.Accept-Encoding ~ "br") {
|
||||
set req.http.Accept-Encoding = "br";
|
||||
} else if (req.http.Accept-Encoding ~ "gzip") {
|
||||
set req.http.Accept-Encoding = "gzip";
|
||||
} else {
|
||||
set req.http.Accept-Encoding = "identity";
|
||||
}
|
||||
}
|
||||
|
||||
# ---- (Opcjonalnie) Normalizacja Accept dla obrazów generowanych wariantowo ----
|
||||
# if (req.url ~ "\.(png|jpe?g|gif|bmp)$") {
|
||||
# if (req.http.Accept ~ "image/webp") {
|
||||
# set req.http.X-Accept-Image = "modern"; # webp
|
||||
# } else {
|
||||
# set req.http.X-Accept-Image = "legacy"; # jpg/png
|
||||
# }
|
||||
# }
|
||||
|
||||
# ---- STATYCZNE – agresywny cache + ignorujemy sesję ----
|
||||
if (req.url ~ "^/static/" || req.url ~ "^/uploads/" || req.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
|
||||
unset req.http.Cookie;
|
||||
unset req.http.Authorization;
|
||||
return (hash);
|
||||
}
|
||||
|
||||
if (!req.http.X-Forwarded-Proto) {
|
||||
set req.http.X-Forwarded-Proto = "https";
|
||||
}
|
||||
|
||||
if (req.url == "/healthcheck" || req.http.X-Internal-Check) {
|
||||
set req.http.X-Pass-Reason = "internal";
|
||||
return (pass);
|
||||
}
|
||||
|
||||
if (req.method != "GET" && req.method != "HEAD") {
|
||||
set req.http.X-Pass-Reason = "method";
|
||||
return (pass);
|
||||
}
|
||||
|
||||
if (req.http.Authorization) {
|
||||
set req.http.X-Pass-Reason = "auth";
|
||||
return (pass);
|
||||
}
|
||||
|
||||
# jeśli chcesz PASS przy cookie:
|
||||
# if (req.http.Cookie) {
|
||||
# set req.http.X-Pass-Reason = "cookie";
|
||||
# return (pass);
|
||||
# }
|
||||
|
||||
return (hash);
|
||||
}
|
||||
|
||||
# ===== PIPE (WebSocket passthrough) =====
|
||||
sub vcl_pipe {
|
||||
if (req.http.Upgrade) {
|
||||
set bereq.http.Upgrade = req.http.Upgrade;
|
||||
set bereq.http.Connection = req.http.Connection;
|
||||
}
|
||||
}
|
||||
|
||||
# ===== HASH =====
|
||||
sub vcl_hash {
|
||||
hash_data(req.url);
|
||||
if (req.http.host) { hash_data(req.http.host); } else { hash_data(server.ip); }
|
||||
|
||||
# Cookie: zostają dla dynamicznych (dla statyków wyczyszczone wcześniej)
|
||||
if (req.http.Cookie) { hash_data(req.http.Cookie); }
|
||||
|
||||
# Accept-Encoding: już znormalizowany do zstd/br/gzip/identity
|
||||
if (req.http.Accept-Encoding) { hash_data(req.http.Accept-Encoding); }
|
||||
|
||||
# (Opcjonalnie) sygnał obrazów z negocjacją po Accept
|
||||
if (req.http.X-Accept-Image) { hash_data(req.http.X-Accept-Image); }
|
||||
}
|
||||
|
||||
# ===== BACKEND_RESPONSE =====
|
||||
sub vcl_backend_response {
|
||||
# Zakaz cache – respektujemy
|
||||
if (beresp.http.Cache-Control ~ "(?i)no-store|private") {
|
||||
set beresp.uncacheable = true;
|
||||
set beresp.ttl = 0s;
|
||||
set beresp.http.X-Pass-Reason = "no-store";
|
||||
return (deliver);
|
||||
}
|
||||
|
||||
# NIE cache'uj redirectów do loginu (HTML) z backendu
|
||||
if (beresp.status >= 300 && beresp.status < 400) {
|
||||
set beresp.uncacheable = true;
|
||||
set beresp.ttl = 0s;
|
||||
set beresp.http.X-Pass-Reason = "redirect";
|
||||
return (deliver);
|
||||
}
|
||||
|
||||
# Nie cache'uj statyków, jeśli status ≠ 200
|
||||
if (bereq.url ~ "^/static/" || bereq.url ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
|
||||
if (beresp.status != 200) {
|
||||
set beresp.uncacheable = true;
|
||||
set beresp.ttl = 0s;
|
||||
return (deliver);
|
||||
}
|
||||
}
|
||||
|
||||
# Jeśli pod .js przychodzi text/html — też nie cache'uj (to zwykle redirect/login)
|
||||
if (bereq.url ~ "\.js(\?.*)?$" && beresp.http.Content-Type ~ "(?i)text/html") {
|
||||
set beresp.uncacheable = true;
|
||||
set beresp.ttl = 0s;
|
||||
return (deliver);
|
||||
}
|
||||
|
||||
# Wymuś poprawny Content-Type dla .js/.css, gdy backend zwróci HTML
|
||||
if (bereq.url ~ "\.js(\?.*)?$") {
|
||||
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
|
||||
set beresp.http.Content-Type = "application/javascript; charset=utf-8";
|
||||
}
|
||||
}
|
||||
if (bereq.url ~ "\.css(\?.*)?$") {
|
||||
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
|
||||
set beresp.http.Content-Type = "text/css; charset=utf-8";
|
||||
}
|
||||
}
|
||||
|
||||
# ---- STATYCZNE: zdejmij Set-Cookie i Vary: Cookie, zapewnij TTL ----
|
||||
if (bereq.url ~ "^/static/" || bereq.url ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
|
||||
unset beresp.http.Set-Cookie;
|
||||
|
||||
# Jeśli backend dodał Vary: Cookie, usuńmy ten element (nie wpływa na statyki)
|
||||
if (beresp.http.Vary) {
|
||||
set beresp.http.Vary = regsuball(beresp.http.Vary, "(?i)(^|,)[[:space:]]*Cookie[[:space:]]*(,|$)", "\1");
|
||||
set beresp.http.Vary = regsuball(beresp.http.Vary, ",[[:space:]]*,", ",");
|
||||
set beresp.http.Vary = regsub(beresp.http.Vary, "^[[:space:]]*,[[:space:]]*", "");
|
||||
set beresp.http.Vary = regsub(beresp.http.Vary, "[[:space:]]*,[[:space:]]*$", "");
|
||||
if (beresp.http.Vary ~ "^[[:space:]]*$") { unset beresp.http.Vary; }
|
||||
}
|
||||
|
||||
# Jeśli brak kontroli czasu życia – ustawiamy twarde wartości
|
||||
if (!(beresp.http.Cache-Control ~ "(?i)(s-maxage|max-age)")) {
|
||||
set beresp.ttl = 24h;
|
||||
set beresp.http.Cache-Control = "public, max-age=86400, immutable";
|
||||
}
|
||||
|
||||
set beresp.grace = 1h;
|
||||
set beresp.keep = 24h;
|
||||
}
|
||||
|
||||
# ---- Ogólne TTL z nagłówków ----
|
||||
if (beresp.http.Cache-Control ~ "(?i)s-maxage=([0-9]+)") {
|
||||
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*s-maxage=([0-9]+).*", "\1") + "s", 0s);
|
||||
} else if (beresp.http.Cache-Control ~ "(?i)max-age=([0-9]+)") {
|
||||
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*max-age=([0-9]+).*", "\1") + "s", 0s);
|
||||
} else if (beresp.http.Expires) {
|
||||
set beresp.ttl = std.time(beresp.http.Expires, now) - now;
|
||||
if (beresp.ttl < 0s) { set beresp.ttl = 0s; }
|
||||
} else {
|
||||
if (beresp.ttl <= 0s) { set beresp.ttl = 60s; }
|
||||
}
|
||||
|
||||
# Immutable => dłuższe grace/keep
|
||||
if (beresp.http.Cache-Control ~ "(?i)immutable") {
|
||||
set beresp.grace = 1h;
|
||||
set beresp.keep = 24h;
|
||||
}
|
||||
|
||||
# Kompresja po stronie Varnisha wyłącznie dla klientów akceptujących gzip
|
||||
# i tylko jeśli backend nie dostarczył już Content-Encoding.
|
||||
if (!beresp.http.Content-Encoding && bereq.http.Accept-Encoding ~ "gzip") {
|
||||
# Kompresujemy tylko „tekstowe” typy; wykluczamy WASM
|
||||
if (beresp.http.Content-Type ~ "(?i)text/|application/(javascript|json|xml)") {
|
||||
set beresp.do_gzip = true;
|
||||
}
|
||||
}
|
||||
|
||||
# Duże odpowiedzi streamujemy
|
||||
if (beresp.http.Content-Length && std.integer(beresp.http.Content-Length, 0) > 1048576) {
|
||||
set beresp.do_stream = true;
|
||||
}
|
||||
}
|
||||
|
||||
# (Opcjonalnie) Serwuj „stale” przy błędach backendu, jeśli jest obiekt w grace
|
||||
sub vcl_backend_error {
|
||||
return (deliver);
|
||||
}
|
||||
|
||||
# ===== DELIVER =====
|
||||
sub vcl_deliver {
|
||||
if (obj.uncacheable) {
|
||||
if (req.http.X-Pass-Reason) {
|
||||
set resp.http.X-Cache = "PASS:" + req.http.X-Pass-Reason;
|
||||
} else if (resp.http.X-Pass-Reason) { # z backendu
|
||||
set resp.http.X-Cache = "PASS:" + resp.http.X-Pass-Reason;
|
||||
} else {
|
||||
set resp.http.X-Cache = "PASS";
|
||||
}
|
||||
unset resp.http.X-Pass-Reason;
|
||||
unset resp.http.Age;
|
||||
} else if (obj.hits > 0) {
|
||||
set resp.http.X-Cache = "HIT";
|
||||
} else {
|
||||
set resp.http.X-Cache = "MISS";
|
||||
unset resp.http.Age;
|
||||
}
|
||||
|
||||
unset resp.http.Via;
|
||||
unset resp.http.X-Varnish;
|
||||
unset resp.http.Server;
|
||||
}
|
||||
|
||||
sub vcl_synth {
|
||||
set resp.http.X-Cache = "SYNTH";
|
||||
}
|
||||
|
||||
# ===== PURGE HANDLER =====
|
||||
sub vcl_purge {
|
||||
return (synth(200, "Purged"));
|
||||
}
|
||||
@@ -1,13 +1,43 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Zatrzymuję i usuwam stare kontenery..."
|
||||
docker compose down --rmi all
|
||||
# --- Wczytaj zmienne z .env ---
|
||||
if [[ -f .env ]]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
APP_PORT="${APP_PORT:-8080}"
|
||||
|
||||
PROFILE=$1
|
||||
|
||||
if [[ -z "$PROFILE" ]]; then
|
||||
echo "Użycie: $0 {pgsql|mysql|sqlite}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Zatrzymuję kontenery aplikacji i bazy..."
|
||||
if [[ "$PROFILE" == "sqlite" ]]; then
|
||||
docker compose stop
|
||||
else
|
||||
docker compose --profile "$PROFILE" stop
|
||||
fi
|
||||
|
||||
echo "Pobieram najnowszy kod z repozytorium..."
|
||||
git pull
|
||||
|
||||
echo "Buduję obrazy i uruchamiam kontenery..."
|
||||
docker compose up -d --build
|
||||
echo "Generowanie default.vcl z APP_PORT=$APP_PORT"
|
||||
envsubst < deploy/varnish/default.vcl.template > deploy/varnish/default.vcl
|
||||
|
||||
echo "Gotowe!"
|
||||
echo "Zapisuję hash commita do version.txt..."
|
||||
git rev-parse --short HEAD > version.txt
|
||||
|
||||
echo "Buduję i uruchamiam kontenery..."
|
||||
if [[ "$PROFILE" == "sqlite" ]]; then
|
||||
docker compose up -d --build
|
||||
else
|
||||
DB_ENGINE="$PROFILE" docker compose --profile "$PROFILE" up -d --build
|
||||
fi
|
||||
|
||||
echo "Gotowe! Wersja aplikacji: $(cat version.txt)"
|
||||
|
||||
@@ -1,18 +1,82 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: live-lista-zakupow
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8000"
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=production
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- SYSTEM_PASSWORD=${SYSTEM_PASSWORD}
|
||||
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME}
|
||||
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD}
|
||||
- UPLOAD_FOLDER=${UPLOAD_FOLDER}
|
||||
- AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE}
|
||||
- AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE}
|
||||
container_name: lista-zakupow-app
|
||||
expose:
|
||||
- "${APP_PORT:-8000}"
|
||||
|
||||
# temporary
|
||||
#ports:
|
||||
# - "9281:${APP_PORT:-8000}"
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8000}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).getcode() == 200 else sys.exit(1)",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./uploads:/app/uploads
|
||||
- ./instance:/app/instance
|
||||
networks:
|
||||
- lista-zakupow_network
|
||||
restart: unless-stopped
|
||||
|
||||
varnish:
|
||||
image: varnish:latest
|
||||
container_name: lista-zakupow-varnish
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:80"
|
||||
volumes:
|
||||
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||
environment:
|
||||
- VARNISH_SIZE=256m
|
||||
networks:
|
||||
- lista-zakupow_network
|
||||
restart: unless-stopped
|
||||
|
||||
mysql:
|
||||
image: mysql:8
|
||||
container_name: lista-zakupow-mysql-db
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
|
||||
volumes:
|
||||
- ./db/mysql:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- lista-zakupow_network
|
||||
profiles: ["mysql"]
|
||||
|
||||
pgsql:
|
||||
image: postgres:18
|
||||
container_name: lista-zakupow-pgsql
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
PGDATA: /var/lib/postgresql
|
||||
volumes:
|
||||
- ./db/pgsql/:/var/lib/postgresql
|
||||
networks:
|
||||
- lista-zakupow_network
|
||||
restart: unless-stopped
|
||||
profiles: ["pgsql"]
|
||||
|
||||
networks:
|
||||
lista-zakupow_network:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
#!/bin/sh
|
||||
flask db upgrade 2>/dev/null || flask create_db
|
||||
|
||||
# Jeśli nie przekazano zmiennej środowiskowej DB_ENGINE, ustaw na sqlite
|
||||
DB_ENGINE=${DB_ENGINE:-sqlite}
|
||||
|
||||
echo "Starting app with database engine: $DB_ENGINE"
|
||||
|
||||
# Czekaj na bazę, jeśli jest inna niż sqlite (np. PostgreSQL)
|
||||
if [ "$DB_ENGINE" != "sqlite" ]; then
|
||||
python _tools/wait_for_db.py --engine "$DB_ENGINE"
|
||||
fi
|
||||
|
||||
# Migracje i start aplikacji
|
||||
flask db upgrade 2>/dev/null || flask db_info
|
||||
|
||||
exec python app.py
|
||||
|
||||
@@ -3,7 +3,19 @@ Flask-SQLAlchemy
|
||||
Flask-Login
|
||||
Flask-SocketIO
|
||||
Flask-Compress
|
||||
eventlet
|
||||
#eventlet
|
||||
gevent-websocket
|
||||
Werkzeug
|
||||
Pillow
|
||||
psutil
|
||||
psutil
|
||||
pillow-heif
|
||||
|
||||
pytesseract
|
||||
opencv-python-headless
|
||||
psycopg2-binary # pgsql
|
||||
pymysql # mysql
|
||||
cryptography # mysql8
|
||||
flask-talisman # nagłówki
|
||||
bcrypt
|
||||
Flask-Session
|
||||
pdf2image
|
||||
11
shopping_app/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .app_setup import app, db, socketio, login_manager, APP_PORT, DEBUG_MODE, static_bp
|
||||
from . import models # noqa: F401
|
||||
from . import helpers # noqa: F401
|
||||
app.register_blueprint(static_bp)
|
||||
from . import web # noqa: F401
|
||||
from . import routes_main # noqa: F401
|
||||
from . import routes_secondary # noqa: F401
|
||||
from . import routes_admin # noqa: F401
|
||||
from . import sockets # noqa: F401
|
||||
|
||||
__all__ = ["app", "db", "socketio", "login_manager", "APP_PORT", "DEBUG_MODE"]
|
||||
127
shopping_app/app_setup.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from .deps import *
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
csp_policy = (
|
||||
{
|
||||
"default-src": "'self'",
|
||||
"script-src": "'self' 'unsafe-inline'",
|
||||
"style-src": "'self' 'unsafe-inline'",
|
||||
"img-src": "'self' data:",
|
||||
"connect-src": "'self'",
|
||||
}
|
||||
if app.config.get("ENABLE_CSP", True)
|
||||
else None
|
||||
)
|
||||
|
||||
permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None
|
||||
|
||||
talisman_kwargs = {
|
||||
"force_https": False,
|
||||
"strict_transport_security": app.config.get("ENABLE_HSTS", True),
|
||||
"frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None,
|
||||
"permissions_policy": permissions_policy,
|
||||
"content_security_policy": csp_policy,
|
||||
"x_content_type_options": app.config.get("ENABLE_XCTO", True),
|
||||
"strict_transport_security_include_subdomains": False,
|
||||
}
|
||||
|
||||
referrer_policy = app.config.get("REFERRER_POLICY")
|
||||
if referrer_policy:
|
||||
talisman_kwargs["referrer_policy"] = referrer_policy
|
||||
|
||||
effective_headers = {
|
||||
k: v
|
||||
for k, v in talisman_kwargs.items()
|
||||
if k != "referrer_policy" and v not in (None, False)
|
||||
}
|
||||
|
||||
if effective_headers:
|
||||
from flask_talisman import Talisman
|
||||
|
||||
talisman = Talisman(
|
||||
app,
|
||||
session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True),
|
||||
**talisman_kwargs,
|
||||
)
|
||||
print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys()))
|
||||
else:
|
||||
print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.")
|
||||
|
||||
register_heif_opener()
|
||||
SQLALCHEMY_ECHO = True
|
||||
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"}
|
||||
SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD")
|
||||
DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME")
|
||||
DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD")
|
||||
UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER")
|
||||
AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE")
|
||||
AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE")
|
||||
HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN")
|
||||
SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES"))
|
||||
SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE")
|
||||
APP_PORT = int(app.config.get("APP_PORT"))
|
||||
app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"]
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
||||
if db_uri.startswith("sqlite:///"):
|
||||
sqlite_path = db_uri.replace("sqlite:///", "", 1)
|
||||
sqlite_dir = os.path.dirname(sqlite_path)
|
||||
if sqlite_dir:
|
||||
os.makedirs(sqlite_dir, exist_ok=True)
|
||||
failed_login_attempts = defaultdict(deque)
|
||||
MAX_ATTEMPTS = 10
|
||||
TIME_WINDOW = 60 * 60
|
||||
WEBP_SAVE_PARAMS = {
|
||||
"format": "WEBP",
|
||||
"lossless": False,
|
||||
"method": 6,
|
||||
"quality": 95,
|
||||
}
|
||||
|
||||
def read_commit(filename="version.txt", root_path=None):
|
||||
base = root_path or os.path.dirname(os.path.abspath(__file__))
|
||||
path = os.path.join(base, filename)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
commit = open(path, "r", encoding="utf-8").read().strip()
|
||||
return commit[:12] if commit else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_file_md5(path):
|
||||
try:
|
||||
digest = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()[:12]
|
||||
except Exception:
|
||||
return "dev"
|
||||
|
||||
|
||||
commit = read_commit("version.txt", root_path=os.path.dirname(os.path.dirname(__file__))) or "dev"
|
||||
APP_VERSION = commit
|
||||
app.config["APP_VERSION"] = APP_VERSION
|
||||
db = SQLAlchemy(app)
|
||||
socketio = SocketIO(app, async_mode="gevent")
|
||||
login_manager = LoginManager(app)
|
||||
login_manager.login_view = "login"
|
||||
app.config["SESSION_TYPE"] = "sqlalchemy"
|
||||
app.config["SESSION_SQLALCHEMY"] = db
|
||||
Session(app)
|
||||
compress = Compress()
|
||||
compress.init_app(app)
|
||||
static_bp = Blueprint("static_bp", __name__)
|
||||
active_users = {}
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
app_start_time = utcnow()
|
||||
39
shopping_app/deps.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import mimetypes
|
||||
import sys
|
||||
import platform
|
||||
import psutil
|
||||
import hashlib
|
||||
import re
|
||||
import traceback
|
||||
import bcrypt
|
||||
import colorsys
|
||||
from pillow_heif import register_heif_opener
|
||||
from datetime import datetime, timedelta, UTC, timezone
|
||||
from urllib.parse import urlparse, urlunparse, urlencode
|
||||
from flask import (
|
||||
Flask, render_template, redirect, url_for, request, flash, Blueprint,
|
||||
send_from_directory, abort, session, jsonify, g, render_template_string
|
||||
)
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import (
|
||||
LoginManager, UserMixin, login_user, login_required, logout_user, current_user
|
||||
)
|
||||
from flask_compress import Compress
|
||||
from flask_socketio import SocketIO, emit, join_room
|
||||
from config import Config
|
||||
from PIL import Image, ExifTags, ImageFilter, ImageOps
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal
|
||||
from sqlalchemy.orm import joinedload, load_only, aliased
|
||||
from collections import defaultdict, deque
|
||||
from functools import wraps
|
||||
from flask_session import Session
|
||||
from types import SimpleNamespace
|
||||
from pdf2image import convert_from_bytes
|
||||
from typing import Sequence, Any
|
||||
import pytesseract
|
||||
from pytesseract import Output
|
||||
import logging
|
||||
1525
shopping_app/helpers.py
Normal file
216
shopping_app/models.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from .deps import *
|
||||
from .app_setup import db, utcnow
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(512), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
# Tabela pośrednia
|
||||
shopping_list_category = db.Table(
|
||||
"shopping_list_category",
|
||||
db.Column(
|
||||
"shopping_list_id",
|
||||
db.Integer,
|
||||
db.ForeignKey("shopping_list.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
db.Column(
|
||||
"category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Category(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
|
||||
|
||||
class ShoppingList(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(150), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
owner = db.relationship("User", backref="lists", foreign_keys=[owner_id])
|
||||
|
||||
is_temporary = db.Column(db.Boolean, default=False)
|
||||
share_token = db.Column(db.String(64), unique=True, nullable=True)
|
||||
expires_at = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
owner = db.relationship("User", backref="lists", lazy=True)
|
||||
is_archived = db.Column(db.Boolean, default=False)
|
||||
is_public = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Relacje
|
||||
items = db.relationship("Item", back_populates="shopping_list", lazy="select")
|
||||
receipts = db.relationship(
|
||||
"Receipt",
|
||||
back_populates="shopping_list",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
)
|
||||
expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select")
|
||||
categories = db.relationship(
|
||||
"Category",
|
||||
secondary=shopping_list_category,
|
||||
backref=db.backref("shopping_lists", lazy="dynamic"),
|
||||
)
|
||||
|
||||
|
||||
class Item(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
# added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
added_at = db.Column(db.DateTime, default=utcnow)
|
||||
added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
added_by_user = db.relationship(
|
||||
"User", backref="added_items", lazy="joined", foreign_keys=[added_by]
|
||||
)
|
||||
|
||||
purchased = db.Column(db.Boolean, default=False)
|
||||
purchased_at = db.Column(db.DateTime, nullable=True)
|
||||
quantity = db.Column(db.Integer, default=1)
|
||||
note = db.Column(db.Text, nullable=True)
|
||||
not_purchased = db.Column(db.Boolean, default=False)
|
||||
not_purchased_reason = db.Column(db.Text, nullable=True)
|
||||
position = db.Column(db.Integer, default=0)
|
||||
|
||||
shopping_list = db.relationship("ShoppingList", back_populates="items")
|
||||
|
||||
|
||||
class SuggestedProduct(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False)
|
||||
usage_count = db.Column(db.Integer, default=0)
|
||||
|
||||
|
||||
class Expense(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
|
||||
amount = db.Column(db.Float, nullable=False)
|
||||
added_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
receipt_filename = db.Column(db.String(255), nullable=True)
|
||||
|
||||
shopping_list = db.relationship("ShoppingList", back_populates="expenses")
|
||||
|
||||
|
||||
class Receipt(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
filesize = db.Column(db.Integer, nullable=True)
|
||||
file_hash = db.Column(db.String(64), nullable=True, unique=True)
|
||||
uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id"))
|
||||
version_token = db.Column(db.String(32), nullable=True)
|
||||
|
||||
shopping_list = db.relationship("ShoppingList", back_populates="receipts")
|
||||
uploaded_by_user = db.relationship("User", backref="uploaded_receipts")
|
||||
|
||||
|
||||
class ListPermission(db.Model):
|
||||
__tablename__ = "list_permission"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
__table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),)
|
||||
|
||||
|
||||
ShoppingList.permitted_users = db.relationship(
|
||||
"User",
|
||||
secondary="list_permission",
|
||||
backref=db.backref("permitted_lists", lazy="dynamic"),
|
||||
lazy="dynamic",
|
||||
)
|
||||
|
||||
|
||||
class AppSetting(db.Model):
|
||||
key = db.Column(db.String(64), primary_key=True)
|
||||
value = db.Column(db.Text, nullable=True)
|
||||
|
||||
|
||||
class ApiToken(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
token_prefix = db.Column(db.String(18), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
last_used_at = db.Column(db.DateTime, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
scopes = db.Column(db.String(255), nullable=False, default="expenses:read")
|
||||
allowed_endpoints = db.Column(db.String(255), nullable=False, default="/api/expenses/latest")
|
||||
max_limit = db.Column(db.Integer, nullable=False, default=100)
|
||||
|
||||
creator = db.relationship(
|
||||
"User", backref="created_api_tokens", lazy="joined", foreign_keys=[created_by]
|
||||
)
|
||||
|
||||
|
||||
class ListTemplate(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
creator = db.relationship("User", backref="list_templates", lazy="joined")
|
||||
items = db.relationship(
|
||||
"ListTemplateItem",
|
||||
back_populates="template",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
order_by="ListTemplateItem.position.asc()",
|
||||
)
|
||||
|
||||
|
||||
class ListTemplateItem(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
template_id = db.Column(db.Integer, db.ForeignKey("list_template.id", ondelete="CASCADE"), nullable=False)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
quantity = db.Column(db.Integer, default=1)
|
||||
note = db.Column(db.Text, nullable=True)
|
||||
position = db.Column(db.Integer, default=0)
|
||||
|
||||
template = db.relationship("ListTemplate", back_populates="items")
|
||||
|
||||
|
||||
class ListActivityLog(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
actor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||
actor_name = db.Column(db.String(150), nullable=False, default="System")
|
||||
action = db.Column(db.String(64), nullable=False)
|
||||
item_name = db.Column(db.String(150), nullable=True)
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
|
||||
|
||||
shopping_list = db.relationship("ShoppingList", backref=db.backref("activity_logs", lazy="dynamic", cascade="all, delete-orphan"))
|
||||
actor = db.relationship("User", backref="list_activity_logs", lazy="joined")
|
||||
|
||||
|
||||
class CategoryColorOverride(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category_id = db.Column(
|
||||
db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False
|
||||
)
|
||||
color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb"
|
||||
|
||||
|
||||
1437
shopping_app/routes_admin.py
Normal file
856
shopping_app/routes_main.py
Normal file
@@ -0,0 +1,856 @@
|
||||
from .deps import *
|
||||
from .app_setup import *
|
||||
from .models import *
|
||||
from .helpers import *
|
||||
|
||||
@app.route("/")
|
||||
def main_page():
|
||||
perm_subq = (
|
||||
user_permission_subq(current_user.id) if current_user.is_authenticated else None
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
month_param = request.args.get("m", None)
|
||||
start = end = None
|
||||
|
||||
if month_param in (None, ""):
|
||||
# domyślnie: bieżący miesiąc
|
||||
month_str = now.strftime("%Y-%m")
|
||||
start = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
|
||||
end = (start + timedelta(days=31)).replace(day=1)
|
||||
elif month_param == "all":
|
||||
month_str = "all"
|
||||
start = end = None
|
||||
else:
|
||||
month_str = month_param
|
||||
try:
|
||||
year, month = map(int, month_str.split("-"))
|
||||
start = datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
end = (start + timedelta(days=31)).replace(day=1)
|
||||
except ValueError:
|
||||
# jeśli m ma zły format – pokaż wszystko
|
||||
month_str = "all"
|
||||
start = end = None
|
||||
|
||||
def date_filter(query):
|
||||
if start and end:
|
||||
query = query.filter(
|
||||
ShoppingList.created_at >= start, ShoppingList.created_at < end
|
||||
)
|
||||
return query
|
||||
|
||||
if current_user.is_authenticated:
|
||||
user_lists = (
|
||||
date_filter(
|
||||
ShoppingList.query.filter(
|
||||
ShoppingList.owner_id == current_user.id,
|
||||
ShoppingList.is_archived == False,
|
||||
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
|
||||
)
|
||||
)
|
||||
.order_by(ShoppingList.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
archived_lists = (
|
||||
ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True)
|
||||
.order_by(ShoppingList.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# publiczne cudze + udzielone mi (po list_permission)
|
||||
public_lists = (
|
||||
date_filter(
|
||||
ShoppingList.query.filter(
|
||||
ShoppingList.owner_id != current_user.id,
|
||||
ShoppingList.is_archived == False,
|
||||
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
|
||||
or_(
|
||||
ShoppingList.is_public == True,
|
||||
ShoppingList.id.in_(perm_subq),
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(ShoppingList.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione
|
||||
else:
|
||||
user_lists = []
|
||||
archived_lists = []
|
||||
public_lists = (
|
||||
date_filter(
|
||||
ShoppingList.query.filter(
|
||||
ShoppingList.is_public == True,
|
||||
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
|
||||
ShoppingList.is_archived == False,
|
||||
)
|
||||
)
|
||||
.order_by(ShoppingList.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
accessible_lists = public_lists # dla gościa = tylko publiczne
|
||||
|
||||
# Zakres miesięcy do selektora
|
||||
if current_user.is_authenticated:
|
||||
visible_lists_query = ShoppingList.query.filter(
|
||||
or_(
|
||||
ShoppingList.owner_id == current_user.id,
|
||||
ShoppingList.is_public == True,
|
||||
ShoppingList.id.in_(perm_subq),
|
||||
)
|
||||
)
|
||||
else:
|
||||
visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True)
|
||||
|
||||
month_options = get_active_months_query(visible_lists_query)
|
||||
|
||||
# Statystyki dla wszystkich widocznych sekcji
|
||||
all_lists = user_lists + accessible_lists + archived_lists
|
||||
all_ids = [l.id for l in all_lists]
|
||||
|
||||
if all_ids:
|
||||
stats = (
|
||||
db.session.query(
|
||||
Item.list_id,
|
||||
func.count(Item.id).label("total_count"),
|
||||
func.sum(case((Item.purchased == True, 1), else_=0)).label(
|
||||
"purchased_count"
|
||||
),
|
||||
func.sum(case((Item.not_purchased == True, 1), else_=0)).label(
|
||||
"not_purchased_count"
|
||||
),
|
||||
)
|
||||
.filter(Item.list_id.in_(all_ids))
|
||||
.group_by(Item.list_id)
|
||||
.all()
|
||||
)
|
||||
stats_map = {
|
||||
s.list_id: (
|
||||
s.total_count or 0,
|
||||
s.purchased_count or 0,
|
||||
s.not_purchased_count or 0,
|
||||
)
|
||||
for s in stats
|
||||
}
|
||||
|
||||
latest_expenses_map = dict(
|
||||
db.session.query(
|
||||
Expense.list_id, func.coalesce(func.sum(Expense.amount), 0)
|
||||
)
|
||||
.filter(Expense.list_id.in_(all_ids))
|
||||
.group_by(Expense.list_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
for l in all_lists:
|
||||
total_count, purchased_count, not_purchased_count = stats_map.get(
|
||||
l.id, (0, 0, 0)
|
||||
)
|
||||
l.total_count = total_count
|
||||
l.purchased_count = purchased_count
|
||||
l.not_purchased_count = not_purchased_count
|
||||
l.total_expense = latest_expenses_map.get(l.id, 0)
|
||||
l.category_badges = [
|
||||
{"name": c.name, "color": category_color_for(c)} for c in l.categories
|
||||
]
|
||||
else:
|
||||
for l in all_lists:
|
||||
l.total_count = 0
|
||||
l.purchased_count = 0
|
||||
l.not_purchased_count = 0
|
||||
l.total_expense = 0
|
||||
l.category_badges = []
|
||||
|
||||
expiring_lists = get_expiring_lists_for_user(current_user.id) if current_user.is_authenticated else []
|
||||
templates = (ListTemplate.query.filter_by(is_active=True, created_by=current_user.id).order_by(ListTemplate.name.asc()).all() if current_user.is_authenticated else [])
|
||||
|
||||
return render_template(
|
||||
"main.html",
|
||||
user_lists=user_lists,
|
||||
public_lists=public_lists,
|
||||
accessible_lists=accessible_lists,
|
||||
archived_lists=archived_lists,
|
||||
now=now,
|
||||
timedelta=timedelta,
|
||||
month_options=month_options,
|
||||
selected_month=month_str,
|
||||
expiring_lists=expiring_lists,
|
||||
templates=templates,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/system-auth", methods=["GET", "POST"])
|
||||
def system_auth():
|
||||
if (
|
||||
current_user.is_authenticated
|
||||
or request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE
|
||||
):
|
||||
flash("Jesteś już zalogowany lub autoryzowany.", "info")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
ip = request.access_route[0]
|
||||
next_page = request.args.get("next") or url_for("main_page")
|
||||
|
||||
if is_ip_blocked(ip):
|
||||
flash(
|
||||
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
|
||||
"danger",
|
||||
)
|
||||
return render_template("system_auth.html"), 403
|
||||
|
||||
if request.method == "POST":
|
||||
if request.form["password"] == SYSTEM_PASSWORD:
|
||||
reset_failed_attempts(ip)
|
||||
resp = redirect(next_page)
|
||||
return set_authorized_cookie(resp)
|
||||
else:
|
||||
register_failed_attempt(ip)
|
||||
if is_ip_blocked(ip):
|
||||
flash(
|
||||
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
|
||||
"danger",
|
||||
)
|
||||
return render_template("system_auth.html"), 403
|
||||
remaining = attempts_remaining(ip)
|
||||
flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning")
|
||||
|
||||
return render_template("system_auth.html")
|
||||
|
||||
|
||||
@app.route("/edit_my_list/<int:list_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_my_list(list_id):
|
||||
# --- Pobranie listy i weryfikacja właściciela ---
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
if l is None:
|
||||
abort(404)
|
||||
if l.owner_id != current_user.id:
|
||||
abort(403, description="Nie jesteś właścicielem tej listy.")
|
||||
|
||||
# Dane do widoku
|
||||
receipts = (
|
||||
Receipt.query.filter_by(list_id=list_id)
|
||||
.order_by(Receipt.uploaded_at.desc())
|
||||
.all()
|
||||
)
|
||||
categories = Category.query.order_by(Category.name.asc()).all()
|
||||
selected_categories_ids = {c.id for c in l.categories}
|
||||
|
||||
next_page = request.args.get("next") or request.referrer
|
||||
wants_json = (
|
||||
"application/json" in (request.headers.get("Accept") or "")
|
||||
or request.headers.get("X-Requested-With") == "fetch"
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
|
||||
# --- Nadanie dostępu (grant) ---
|
||||
if action == "grant":
|
||||
grant_username = (request.form.get("grant_username") or "").strip().lower()
|
||||
if not grant_username:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="empty"), 400
|
||||
flash("Podaj nazwę użytkownika do nadania dostępu.", "danger")
|
||||
return redirect(next_page or request.url)
|
||||
|
||||
u = User.query.filter(func.lower(User.username) == grant_username).first()
|
||||
if not u:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="not_found"), 404
|
||||
flash("Użytkownik nie istnieje.", "danger")
|
||||
return redirect(next_page or request.url)
|
||||
if u.id == current_user.id:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="owner"), 409
|
||||
flash("Jesteś właścicielem tej listy.", "info")
|
||||
return redirect(next_page or request.url)
|
||||
|
||||
exists = (
|
||||
db.session.query(ListPermission.id)
|
||||
.filter(
|
||||
ListPermission.list_id == l.id,
|
||||
ListPermission.user_id == u.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not exists:
|
||||
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
|
||||
db.session.commit()
|
||||
if wants_json:
|
||||
return jsonify(ok=True, user={"id": u.id, "username": u.username})
|
||||
flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success")
|
||||
else:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="exists"), 409
|
||||
flash("Ten użytkownik już ma dostęp.", "info")
|
||||
return redirect(next_page or request.url)
|
||||
|
||||
# --- Odebranie dostępu (revoke) ---
|
||||
revoke_user_id = request.form.get("revoke_user_id")
|
||||
if revoke_user_id:
|
||||
try:
|
||||
uid = int(revoke_user_id)
|
||||
except ValueError:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="bad_id"), 400
|
||||
flash("Błędny identyfikator użytkownika.", "danger")
|
||||
return redirect(next_page or request.url)
|
||||
|
||||
ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete()
|
||||
db.session.commit()
|
||||
if wants_json:
|
||||
return jsonify(ok=True, removed_user_id=uid)
|
||||
flash("Odebrano dostęp użytkownikowi.", "success")
|
||||
return redirect(next_page or request.url)
|
||||
|
||||
# --- Przywracanie z archiwum ---
|
||||
if "unarchive" in request.form:
|
||||
l.is_archived = False
|
||||
db.session.commit()
|
||||
if wants_json:
|
||||
return jsonify(ok=True, unarchived=True)
|
||||
flash(f"Lista „{l.title}” została przywrócona.", "success")
|
||||
return redirect(next_page or request.url)
|
||||
|
||||
# --- Główny zapis pól formularza ---
|
||||
move_to_month = request.form.get("move_to_month")
|
||||
if move_to_month:
|
||||
try:
|
||||
year, month = map(int, move_to_month.split("-"))
|
||||
l.created_at = datetime(year, month, 1, tzinfo=timezone.utc)
|
||||
if not wants_json:
|
||||
flash(
|
||||
f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}",
|
||||
"success",
|
||||
)
|
||||
except ValueError:
|
||||
if not wants_json:
|
||||
flash(
|
||||
"Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.",
|
||||
"danger",
|
||||
)
|
||||
|
||||
new_title = (request.form.get("title") or "").strip()
|
||||
is_public = "is_public" in request.form
|
||||
is_temporary = "is_temporary" in request.form
|
||||
is_archived = "is_archived" in request.form
|
||||
expires_date = request.form.get("expires_date")
|
||||
expires_time = request.form.get("expires_time")
|
||||
|
||||
if not new_title:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="title_empty"), 400
|
||||
flash("Podaj poprawny tytuł", "danger")
|
||||
return redirect(next_page or request.url)
|
||||
|
||||
l.title = new_title
|
||||
l.is_public = is_public
|
||||
l.is_temporary = is_temporary
|
||||
l.is_archived = is_archived
|
||||
|
||||
if expires_date and expires_time:
|
||||
try:
|
||||
combined = f"{expires_date} {expires_time}"
|
||||
expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M")
|
||||
l.expires_at = expires_dt.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="bad_expiry"), 400
|
||||
flash("Błędna data lub godzina wygasania", "danger")
|
||||
return redirect(next_page or request.url)
|
||||
else:
|
||||
l.expires_at = None
|
||||
|
||||
# Kategorie (używa Twojej pomocniczej funkcji)
|
||||
update_list_categories_from_form(l, request.form)
|
||||
|
||||
db.session.commit()
|
||||
if wants_json:
|
||||
return jsonify(ok=True, saved=True)
|
||||
flash("Zaktualizowano dane listy", "success")
|
||||
return redirect(next_page or request.url)
|
||||
|
||||
# GET: użytkownicy z dostępem
|
||||
permitted_users = (
|
||||
db.session.query(User)
|
||||
.join(ListPermission, ListPermission.user_id == User.id)
|
||||
.where(ListPermission.list_id == l.id)
|
||||
.order_by(User.username.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
all_usernames = [
|
||||
u.username
|
||||
for u in User.query.filter(User.id != current_user.id)
|
||||
.order_by(func.lower(User.username).asc())
|
||||
.limit(300)
|
||||
.all()
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"edit_my_list.html",
|
||||
list=l,
|
||||
receipts=receipts,
|
||||
categories=categories,
|
||||
selected_categories=selected_categories_ids,
|
||||
permitted_users=permitted_users,
|
||||
all_usernames=all_usernames,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/edit_my_list/<int:list_id>/suggestions", methods=["GET"])
|
||||
@login_required
|
||||
def edit_my_list_suggestions(list_id: int):
|
||||
# Weryfikacja listy i właściciela (prywatność)
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
if l is None:
|
||||
abort(404)
|
||||
if l.owner_id != current_user.id:
|
||||
abort(403, description="Nie jesteś właścicielem tej listy.")
|
||||
|
||||
q = (request.args.get("q") or "").strip().lower()
|
||||
|
||||
# Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach)
|
||||
subq = (
|
||||
db.session.query(
|
||||
ListPermission.user_id.label("uid"),
|
||||
func.count(ListPermission.id).label("grant_count"),
|
||||
func.max(ListPermission.id).label("last_grant_id"),
|
||||
)
|
||||
.join(ShoppingList, ShoppingList.id == ListPermission.list_id)
|
||||
.filter(ShoppingList.owner_id == current_user.id)
|
||||
.group_by(ListPermission.user_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id)
|
||||
.outerjoin(subq, subq.c.uid == User.id)
|
||||
.filter(User.id != current_user.id)
|
||||
)
|
||||
if q:
|
||||
query = query.filter(func.lower(User.username).like(f"{q}%"))
|
||||
|
||||
rows = (
|
||||
query.order_by(
|
||||
func.coalesce(subq.c.grant_count, 0).desc(),
|
||||
func.coalesce(subq.c.last_grant_id, 0).desc(),
|
||||
func.lower(User.username).asc(),
|
||||
)
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
|
||||
return jsonify({"users": [r.username for r in rows]})
|
||||
|
||||
|
||||
@app.route("/delete_user_list/<int:list_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_user_list(list_id):
|
||||
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
if l is None or l.owner_id != current_user.id:
|
||||
abort(403, description="Nie jesteś właścicielem tej listy.")
|
||||
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
if l is None or l.owner_id != current_user.id:
|
||||
abort(403)
|
||||
delete_receipts_for_list(list_id)
|
||||
Item.query.filter_by(list_id=list_id).delete()
|
||||
Expense.query.filter_by(list_id=list_id).delete()
|
||||
db.session.delete(l)
|
||||
db.session.commit()
|
||||
flash("Lista została usunięta", "success")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
|
||||
@app.route("/toggle_visibility/<int:list_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def toggle_visibility(list_id):
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
if l is None:
|
||||
abort(404)
|
||||
|
||||
if l.owner_id != current_user.id:
|
||||
if request.is_json or request.method == "POST":
|
||||
return {"error": "Unauthorized"}, 403
|
||||
flash("Nie masz uprawnień do tej listy", "danger")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
l.is_public = not l.is_public
|
||||
db.session.commit()
|
||||
|
||||
share_url = f"{request.url_root}share/{l.share_token}"
|
||||
|
||||
if request.is_json or request.method == "POST":
|
||||
return {"is_public": l.is_public, "share_url": share_url}
|
||||
|
||||
if l.is_public:
|
||||
flash("Lista została udostępniona publicznie", "success")
|
||||
else:
|
||||
flash("Lista została ukryta przed gośćmi", "info")
|
||||
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
username_input = request.form["username"].lower()
|
||||
user = User.query.filter(func.lower(User.username) == username_input).first()
|
||||
if user and check_password(user.password_hash, request.form["password"]):
|
||||
session.permanent = True
|
||||
login_user(user)
|
||||
session.modified = True
|
||||
flash("Zalogowano pomyślnie", "success")
|
||||
return redirect(url_for("main_page"))
|
||||
flash("Nieprawidłowy login lub hasło", "danger")
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash("Wylogowano pomyślnie", "success")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
|
||||
@app.route("/create", methods=["POST"])
|
||||
@login_required
|
||||
def create_list():
|
||||
title = request.form.get("title")
|
||||
is_temporary = request.form.get("temporary") == "1"
|
||||
token = generate_share_token(8)
|
||||
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None
|
||||
)
|
||||
|
||||
new_list = ShoppingList(
|
||||
title=title,
|
||||
owner_id=current_user.id,
|
||||
is_temporary=is_temporary,
|
||||
share_token=token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.session.add(new_list)
|
||||
db.session.commit()
|
||||
log_list_activity(new_list.id, 'list_created', actor=current_user, actor_name=current_user.username, details='Utworzono listę ręcznie')
|
||||
db.session.commit()
|
||||
flash("Utworzono nową listę", "success")
|
||||
return redirect(url_for("view_list", list_id=new_list.id))
|
||||
|
||||
|
||||
@app.route("/list/<int:list_id>")
|
||||
@login_required
|
||||
def view_list(list_id):
|
||||
shopping_list = db.session.get(ShoppingList, list_id)
|
||||
if not shopping_list:
|
||||
abort(404)
|
||||
|
||||
is_owner = current_user.id == shopping_list.owner_id
|
||||
if not is_owner:
|
||||
flash(
|
||||
"Nie jesteś właścicielem listy, przekierowano do widoku publicznego.",
|
||||
"warning",
|
||||
)
|
||||
if current_user.is_admin:
|
||||
flash(
|
||||
"W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info"
|
||||
)
|
||||
return redirect(url_for("shared_list", token=shopping_list.share_token))
|
||||
|
||||
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
|
||||
total_count = len(items)
|
||||
purchased_count = len([i for i in items if i.purchased])
|
||||
percent = (purchased_count / total_count * 100) if total_count > 0 else 0
|
||||
|
||||
for item in items:
|
||||
if item.added_by != shopping_list.owner_id:
|
||||
item.added_by_display = (
|
||||
item.added_by_user.username if item.added_by_user else "?"
|
||||
)
|
||||
else:
|
||||
item.added_by_display = None
|
||||
|
||||
shopping_list.category_badges = [
|
||||
{"name": c.name, "color": category_color_for(c)}
|
||||
for c in shopping_list.categories
|
||||
]
|
||||
|
||||
# Wszystkie kategorie (do selecta)
|
||||
categories = Category.query.order_by(Category.name.asc()).all()
|
||||
selected_categories_ids = {c.id for c in shopping_list.categories}
|
||||
|
||||
# Najczęściej używane kategorie właściciela (top N)
|
||||
popular_categories = (
|
||||
db.session.query(Category)
|
||||
.join(
|
||||
shopping_list_category,
|
||||
shopping_list_category.c.category_id == Category.id,
|
||||
)
|
||||
.join(
|
||||
ShoppingList,
|
||||
ShoppingList.id == shopping_list_category.c.shopping_list_id,
|
||||
)
|
||||
.filter(ShoppingList.owner_id == current_user.id)
|
||||
.group_by(Category.id)
|
||||
.order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc())
|
||||
.limit(6)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Użytkownicy z uprawnieniami do listy
|
||||
permitted_users = (
|
||||
db.session.query(User)
|
||||
.join(ListPermission, ListPermission.user_id == User.id)
|
||||
.filter(ListPermission.list_id == shopping_list.id)
|
||||
.order_by(User.username.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
activity_logs = (
|
||||
ListActivityLog.query.filter_by(list_id=list_id)
|
||||
.order_by(ListActivityLog.created_at.desc(), ListActivityLog.id.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
|
||||
all_usernames = [
|
||||
u.username
|
||||
for u in User.query.filter(User.id != current_user.id)
|
||||
.order_by(func.lower(User.username).asc())
|
||||
.limit(300)
|
||||
.all()
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"list.html",
|
||||
list=shopping_list,
|
||||
items=items,
|
||||
receipts=receipts,
|
||||
total_count=total_count,
|
||||
purchased_count=purchased_count,
|
||||
percent=percent,
|
||||
expenses=expenses,
|
||||
total_expense=total_expense,
|
||||
is_share=False,
|
||||
is_owner=is_owner,
|
||||
categories=categories,
|
||||
selected_categories=selected_categories_ids,
|
||||
permitted_users=permitted_users,
|
||||
popular_categories=popular_categories,
|
||||
activity_logs=activity_logs,
|
||||
action_label=action_label,
|
||||
all_usernames=all_usernames,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/list/<int:list_id>/settings", methods=["POST"])
|
||||
@login_required
|
||||
def list_settings(list_id):
|
||||
# Uprawnienia: właściciel
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
if l is None:
|
||||
abort(404)
|
||||
if l.owner_id != current_user.id:
|
||||
abort(403, description="Brak uprawnień do ustawień tej listy.")
|
||||
|
||||
next_page = request.form.get("next") or url_for("view_list", list_id=list_id)
|
||||
wants_json = (
|
||||
"application/json" in (request.headers.get("Accept") or "")
|
||||
or request.headers.get("X-Requested-With") == "fetch"
|
||||
)
|
||||
|
||||
action = request.form.get("action")
|
||||
|
||||
# 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii)
|
||||
if action == "set_category":
|
||||
cid = request.form.get("category_id")
|
||||
if cid in (None, "", "none"):
|
||||
# usunięcie kategorii lub brak zmiany – w zależności od Twojej logiki
|
||||
l.categories = []
|
||||
db.session.commit()
|
||||
if wants_json:
|
||||
return jsonify(ok=True, saved=True)
|
||||
flash("Zapisano kategorię.", "success")
|
||||
return redirect(next_page)
|
||||
|
||||
try:
|
||||
cid = int(cid)
|
||||
except (TypeError, ValueError):
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="bad_category"), 400
|
||||
flash("Błędna kategoria.", "danger")
|
||||
return redirect(next_page)
|
||||
|
||||
c = db.session.get(Category, cid)
|
||||
if not c:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="bad_category"), 400
|
||||
flash("Błędna kategoria.", "danger")
|
||||
return redirect(next_page)
|
||||
|
||||
# Jeśli jeden wybór – zastąp listę kategorii jedną:
|
||||
l.categories = [c]
|
||||
db.session.commit()
|
||||
if wants_json:
|
||||
return jsonify(ok=True, saved=True)
|
||||
flash("Zapisano kategorię.", "success")
|
||||
return redirect(next_page)
|
||||
|
||||
# 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant')
|
||||
if action in ("grant_access", "grant"):
|
||||
grant_username = (request.form.get("grant_username") or "").strip().lower()
|
||||
|
||||
if not grant_username:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="empty_username"), 400
|
||||
flash("Podaj nazwę użytkownika.", "danger")
|
||||
return redirect(next_page)
|
||||
|
||||
# Szukamy użytkownika po username (case-insensitive)
|
||||
u = User.query.filter(func.lower(User.username) == grant_username).first()
|
||||
if not u:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="not_found"), 404
|
||||
flash("Użytkownik nie istnieje.", "danger")
|
||||
return redirect(next_page)
|
||||
|
||||
# Właściciel już ma dostęp
|
||||
if u.id == l.owner_id:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="owner"), 409
|
||||
flash("Jesteś właścicielem tej listy.", "info")
|
||||
return redirect(next_page)
|
||||
|
||||
# Czy już ma dostęp?
|
||||
exists = (
|
||||
db.session.query(ListPermission.id)
|
||||
.filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="exists"), 409
|
||||
flash("Ten użytkownik już ma dostęp.", "info")
|
||||
return redirect(next_page)
|
||||
|
||||
# Zapis uprawnienia
|
||||
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
|
||||
db.session.commit()
|
||||
|
||||
if wants_json:
|
||||
# Zwracamy usera, żeby JS mógł dokleić token bez odświeżania
|
||||
return jsonify(ok=True, user={"id": u.id, "username": u.username})
|
||||
flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success")
|
||||
return redirect(next_page)
|
||||
|
||||
# 3) Odebranie dostępu (po polu revoke_user_id, nie po action)
|
||||
revoke_uid = request.form.get("revoke_user_id")
|
||||
if revoke_uid:
|
||||
try:
|
||||
uid = int(revoke_uid)
|
||||
except (TypeError, ValueError):
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="bad_user_id"), 400
|
||||
flash("Błędny identyfikator użytkownika.", "danger")
|
||||
return redirect(next_page)
|
||||
|
||||
# Nie pozwalaj usunąć właściciela
|
||||
if uid == l.owner_id:
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="cannot_revoke_owner"), 400
|
||||
flash("Nie można odebrać dostępu właścicielowi.", "danger")
|
||||
return redirect(next_page)
|
||||
|
||||
ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete()
|
||||
db.session.commit()
|
||||
|
||||
if wants_json:
|
||||
return jsonify(ok=True, removed_user_id=uid)
|
||||
flash("Odebrano dostęp użytkownikowi.", "success")
|
||||
return redirect(next_page)
|
||||
|
||||
# 4) Nieznana akcja
|
||||
if wants_json:
|
||||
return jsonify(ok=False, error="unknown_action"), 400
|
||||
flash("Nieznana akcja.", "danger")
|
||||
return redirect(next_page)
|
||||
|
||||
|
||||
@app.route('/my-templates', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def my_templates():
|
||||
if request.method == 'POST':
|
||||
action = (request.form.get('action') or 'create_manual').strip()
|
||||
if action == 'create_manual':
|
||||
name = (request.form.get('name') or '').strip()
|
||||
description = (request.form.get('description') or '').strip()
|
||||
raw_items = (request.form.get('items_text') or '').splitlines()
|
||||
if not name:
|
||||
flash('Podaj nazwę szablonu.', 'danger')
|
||||
return redirect(url_for('my_templates'))
|
||||
template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True)
|
||||
db.session.add(template)
|
||||
db.session.flush()
|
||||
pos = 1
|
||||
for line in raw_items:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
qty = 1
|
||||
item_name = line
|
||||
match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line)
|
||||
if match:
|
||||
item_name = (match.group(1) or '').strip() or line
|
||||
if match.group(2):
|
||||
qty = max(1, int(match.group(2)))
|
||||
db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos))
|
||||
pos += 1
|
||||
db.session.commit()
|
||||
flash(f'Utworzono szablon „{template.name}”.', 'success')
|
||||
return redirect(url_for('my_templates'))
|
||||
elif action == 'delete':
|
||||
template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int))
|
||||
if template.created_by != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
db.session.delete(template)
|
||||
db.session.commit()
|
||||
flash(f'Usunięto szablon „{template.name}”.', 'warning')
|
||||
return redirect(url_for('my_templates'))
|
||||
|
||||
templates = ListTemplate.query.options(joinedload(ListTemplate.items)).filter_by(created_by=current_user.id, is_active=True).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all()
|
||||
source_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).order_by(ShoppingList.created_at.desc()).limit(100).all()
|
||||
return render_template('my_templates.html', templates=templates, source_lists=source_lists)
|
||||
|
||||
|
||||
@app.route('/templates/<int:template_id>/instantiate', methods=['POST'])
|
||||
@login_required
|
||||
def instantiate_template(template_id):
|
||||
template = ListTemplate.query.get_or_404(template_id)
|
||||
if not template_is_accessible_to_user(template, current_user):
|
||||
abort(403)
|
||||
title = (request.form.get('title') or '').strip() or None
|
||||
new_list = create_list_from_template(template, owner=current_user, title=title)
|
||||
log_list_activity(new_list.id, 'template_created', actor=current_user, details=f'Utworzono z szablonu: {template.name}')
|
||||
db.session.commit()
|
||||
flash(f'Utworzono listę z szablonu „{template.name}”.', 'success')
|
||||
return redirect(url_for('view_list', list_id=new_list.id))
|
||||
|
||||
|
||||
@app.route('/templates/create-from-list/<int:list_id>', methods=['POST'])
|
||||
@login_required
|
||||
def create_template_from_user_list(list_id):
|
||||
source_list = ShoppingList.query.options(joinedload(ShoppingList.items)).get_or_404(list_id)
|
||||
if source_list.owner_id != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
name = (request.form.get('template_name') or '').strip() or f'{source_list.title} - szablon'
|
||||
description = (request.form.get('description') or '').strip() or f'Szablon utworzony z listy {source_list.title}'
|
||||
template = create_template_from_list(source_list, created_by=current_user.id, name=name, description=description)
|
||||
flash(f'Utworzono szablon „{template.name}”.', 'success')
|
||||
return redirect(url_for('my_templates'))
|
||||
740
shopping_app/routes_secondary.py
Normal file
@@ -0,0 +1,740 @@
|
||||
from .deps import *
|
||||
from .app_setup import *
|
||||
from .models import *
|
||||
from .helpers import *
|
||||
|
||||
@app.route("/expenses")
|
||||
@login_required
|
||||
def expenses():
|
||||
start_date_str = request.args.get("start_date")
|
||||
end_date_str = request.args.get("end_date")
|
||||
category_id = request.args.get("category_id", type=str)
|
||||
show_all = request.args.get("show_all", "true").lower() == "true"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
visible_clause = visible_lists_clause_for_expenses(
|
||||
user_id=current_user.id, include_shared=show_all, now_dt=now
|
||||
)
|
||||
|
||||
lists_q = ShoppingList.query.filter(*visible_clause)
|
||||
|
||||
if start_date_str and end_date_str:
|
||||
try:
|
||||
start = datetime.strptime(start_date_str, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1)
|
||||
lists_q = lists_q.filter(
|
||||
ShoppingList.created_at >= start,
|
||||
ShoppingList.created_at < end,
|
||||
)
|
||||
except ValueError:
|
||||
flash("Błędny zakres dat", "danger")
|
||||
|
||||
if category_id:
|
||||
if category_id == "none":
|
||||
lists_q = lists_q.filter(~ShoppingList.categories.any())
|
||||
else:
|
||||
try:
|
||||
cid = int(category_id)
|
||||
lists_q = lists_q.join(
|
||||
shopping_list_category,
|
||||
shopping_list_category.c.shopping_list_id == ShoppingList.id,
|
||||
).filter(shopping_list_category.c.category_id == cid)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
lists_filtered = (
|
||||
lists_q.options(
|
||||
joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)
|
||||
)
|
||||
.order_by(ShoppingList.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
list_ids = [l.id for l in lists_filtered] or [-1]
|
||||
|
||||
expenses = (
|
||||
Expense.query.options(
|
||||
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
|
||||
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
|
||||
)
|
||||
.filter(Expense.list_id.in_(list_ids))
|
||||
.order_by(Expense.added_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
totals_rows = (
|
||||
db.session.query(
|
||||
ShoppingList.id.label("lid"),
|
||||
func.coalesce(func.sum(Expense.amount), 0).label("total_expense"),
|
||||
)
|
||||
.select_from(ShoppingList)
|
||||
.filter(ShoppingList.id.in_(list_ids))
|
||||
.outerjoin(Expense, Expense.list_id == ShoppingList.id)
|
||||
.group_by(ShoppingList.id)
|
||||
.all()
|
||||
)
|
||||
totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows}
|
||||
|
||||
categories = (
|
||||
Category.query.join(
|
||||
shopping_list_category, shopping_list_category.c.category_id == Category.id
|
||||
)
|
||||
.join(
|
||||
ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id
|
||||
)
|
||||
.filter(ShoppingList.id.in_(list_ids))
|
||||
.distinct()
|
||||
.order_by(Category.name.asc())
|
||||
.all()
|
||||
)
|
||||
categories.append(SimpleNamespace(id="none", name="Bez kategorii"))
|
||||
|
||||
expense_table = [
|
||||
{
|
||||
"title": (e.shopping_list.title if e.shopping_list else "Nieznana"),
|
||||
"amount": e.amount,
|
||||
"added_at": e.added_at,
|
||||
}
|
||||
for e in expenses
|
||||
]
|
||||
|
||||
lists_data = [
|
||||
{
|
||||
"id": l.id,
|
||||
"title": l.title,
|
||||
"created_at": l.created_at,
|
||||
"total_expense": totals_map.get(l.id, 0.0),
|
||||
"owner_username": l.owner.username if l.owner else "?",
|
||||
"categories": [c.id for c in l.categories],
|
||||
}
|
||||
for l in lists_filtered
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"expenses.html",
|
||||
expense_table=expense_table,
|
||||
lists_data=lists_data,
|
||||
categories=categories,
|
||||
selected_category=category_id,
|
||||
show_all=show_all,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/expenses_data")
|
||||
@login_required
|
||||
def expenses_data():
|
||||
range_type = request.args.get("range", "monthly")
|
||||
start_date = request.args.get("start_date")
|
||||
end_date = request.args.get("end_date")
|
||||
show_all = request.args.get("show_all", "true").lower() == "true"
|
||||
category_id = request.args.get("category_id")
|
||||
by_category = request.args.get("by_category", "false").lower() == "true"
|
||||
|
||||
if not start_date or not end_date:
|
||||
sd, ed, bucket = resolve_range(range_type)
|
||||
if sd and ed:
|
||||
start_date = sd
|
||||
end_date = ed
|
||||
range_type = bucket
|
||||
|
||||
if by_category:
|
||||
result = get_total_expenses_grouped_by_category(
|
||||
show_all=show_all,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=current_user.id,
|
||||
category_id=category_id,
|
||||
)
|
||||
else:
|
||||
result = get_total_expenses_grouped_by_list_created_at(
|
||||
user_only=False,
|
||||
admin=False,
|
||||
show_all=show_all,
|
||||
range_type=range_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=current_user.id,
|
||||
category_id=category_id,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
return jsonify({"error": result["error"]}), 400
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/expenses/latest")
|
||||
@api_token_required
|
||||
@require_api_scope('expenses:read')
|
||||
def api_latest_expenses():
|
||||
start_date_str = (request.args.get("start_date") or "").strip() or None
|
||||
end_date_str = (request.args.get("end_date") or "").strip() or None
|
||||
list_id = request.args.get("list_id", type=int)
|
||||
owner_id = request.args.get("owner_id", type=int)
|
||||
limit = request.args.get("limit", default=50, type=int) or 50
|
||||
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
|
||||
limit = max(1, min(limit, int(token_limit or 500), 500))
|
||||
|
||||
try:
|
||||
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
|
||||
except ValueError as exc:
|
||||
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
|
||||
|
||||
filter_query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
|
||||
|
||||
if start_date:
|
||||
filter_query = filter_query.filter(Expense.added_at >= start_date)
|
||||
if end_date:
|
||||
filter_query = filter_query.filter(Expense.added_at < end_date)
|
||||
if list_id:
|
||||
filter_query = filter_query.filter(Expense.list_id == list_id)
|
||||
if owner_id:
|
||||
filter_query = filter_query.filter(ShoppingList.owner_id == owner_id)
|
||||
|
||||
total_count = filter_query.with_entities(func.count(Expense.id)).scalar() or 0
|
||||
total_amount = float(filter_query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
|
||||
|
||||
expenses = (
|
||||
filter_query.options(
|
||||
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
|
||||
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
|
||||
)
|
||||
.order_by(Expense.added_at.desc(), Expense.id.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = []
|
||||
for expense in expenses:
|
||||
shopping_list = expense.shopping_list
|
||||
owner = shopping_list.owner if shopping_list else None
|
||||
items.append(
|
||||
{
|
||||
"expense_id": expense.id,
|
||||
"amount": round(float(expense.amount or 0), 2),
|
||||
"added_at": format_dt_for_api(expense.added_at),
|
||||
"receipt_filename": expense.receipt_filename,
|
||||
"list": {
|
||||
"id": shopping_list.id if shopping_list else None,
|
||||
"title": shopping_list.title if shopping_list else None,
|
||||
"created_at": format_dt_for_api(shopping_list.created_at if shopping_list else None),
|
||||
"is_archived": bool(shopping_list.is_archived) if shopping_list else None,
|
||||
"is_public": bool(shopping_list.is_public) if shopping_list else None,
|
||||
"categories": [c.name for c in shopping_list.categories] if shopping_list else [],
|
||||
},
|
||||
"owner": {
|
||||
"id": owner.id if owner else None,
|
||||
"username": owner.username if owner else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"filters": {
|
||||
"start_date": start_date_str,
|
||||
"end_date": end_date_str,
|
||||
"list_id": list_id,
|
||||
"owner_id": owner_id,
|
||||
"limit": limit,
|
||||
},
|
||||
"meta": {
|
||||
"returned_count": len(items),
|
||||
"total_count": int(total_count),
|
||||
"total_amount": round(total_amount, 2),
|
||||
"token_name": g.api_token.name,
|
||||
"token_prefix": g.api_token.token_prefix,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/ping")
|
||||
@api_token_required
|
||||
def api_ping():
|
||||
return jsonify({"ok": True, "message": "token accepted", "token_name": g.api_token.name, "token_prefix": g.api_token.token_prefix})
|
||||
|
||||
|
||||
@app.route("/api/expenses/summary")
|
||||
@api_token_required
|
||||
@require_api_scope('expenses:read')
|
||||
def api_expenses_summary():
|
||||
start_date_str = (request.args.get("start_date") or "").strip() or None
|
||||
end_date_str = (request.args.get("end_date") or "").strip() or None
|
||||
list_id = request.args.get("list_id", type=int)
|
||||
owner_id = request.args.get("owner_id", type=int)
|
||||
|
||||
try:
|
||||
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
|
||||
except ValueError as exc:
|
||||
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
|
||||
|
||||
query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
|
||||
if start_date:
|
||||
query = query.filter(Expense.added_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(Expense.added_at < end_date)
|
||||
if list_id:
|
||||
query = query.filter(Expense.list_id == list_id)
|
||||
if owner_id:
|
||||
query = query.filter(ShoppingList.owner_id == owner_id)
|
||||
|
||||
total_count = int(query.with_entities(func.count(Expense.id)).scalar() or 0)
|
||||
total_amount = float(query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
|
||||
by_list = (
|
||||
query.with_entities(ShoppingList.id, ShoppingList.title, func.count(Expense.id), func.coalesce(func.sum(Expense.amount), 0))
|
||||
.group_by(ShoppingList.id, ShoppingList.title)
|
||||
.order_by(func.coalesce(func.sum(Expense.amount), 0).desc(), ShoppingList.id.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"filters": {"start_date": start_date_str, "end_date": end_date_str, "list_id": list_id, "owner_id": owner_id},
|
||||
"meta": {"total_count": total_count, "total_amount": round(total_amount, 2)},
|
||||
"lists": [{"id": row[0], "title": row[1], "expense_count": int(row[2] or 0), "total_amount": round(float(row[3] or 0), 2)} for row in by_list],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/lists")
|
||||
@api_token_required
|
||||
@require_api_scope('lists:read')
|
||||
def api_lists():
|
||||
owner_id = request.args.get("owner_id", type=int)
|
||||
limit = request.args.get("limit", default=50, type=int) or 50
|
||||
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
|
||||
limit = max(1, min(limit, int(token_limit or 500), 500))
|
||||
|
||||
query = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc(), ShoppingList.id.desc())
|
||||
if owner_id:
|
||||
query = query.filter(ShoppingList.owner_id == owner_id)
|
||||
rows = query.limit(limit).all()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"items": [{
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"created_at": format_dt_for_api(row.created_at),
|
||||
"owner": {"id": row.owner.id if row.owner else None, "username": row.owner.username if row.owner else None},
|
||||
"is_temporary": bool(row.is_temporary),
|
||||
"expires_at": format_dt_for_api(row.expires_at),
|
||||
"is_archived": bool(row.is_archived),
|
||||
"is_public": bool(row.is_public),
|
||||
"categories": [c.name for c in row.categories],
|
||||
} for row in rows],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/lists/<int:list_id>/expenses")
|
||||
@api_token_required
|
||||
@require_api_scope('lists:read')
|
||||
def api_list_expenses(list_id):
|
||||
limit = request.args.get("limit", default=50, type=int) or 50
|
||||
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
|
||||
limit = max(1, min(limit, int(token_limit or 500), 500))
|
||||
shopping_list = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).get_or_404(list_id)
|
||||
rows = Expense.query.filter_by(list_id=list_id).order_by(Expense.added_at.desc(), Expense.id.desc()).limit(limit).all()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"list": {
|
||||
"id": shopping_list.id,
|
||||
"title": shopping_list.title,
|
||||
"owner": {"id": shopping_list.owner.id if shopping_list.owner else None, "username": shopping_list.owner.username if shopping_list.owner else None},
|
||||
"categories": [c.name for c in shopping_list.categories],
|
||||
},
|
||||
"items": [{"expense_id": row.id, "amount": round(float(row.amount or 0), 2), "added_at": format_dt_for_api(row.added_at), "receipt_filename": row.receipt_filename} for row in rows],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/templates")
|
||||
@api_token_required
|
||||
@require_api_scope('templates:read')
|
||||
def api_templates():
|
||||
query = ListTemplate.query.options(joinedload(ListTemplate.creator), joinedload(ListTemplate.items)).filter_by(is_active=True)
|
||||
owner_id = request.args.get("owner_id", type=int)
|
||||
if owner_id:
|
||||
query = query.filter(ListTemplate.created_by == owner_id)
|
||||
rows = query.order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).limit(100).all()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"items": [{
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"description": row.description,
|
||||
"created_at": format_dt_for_api(row.created_at),
|
||||
"owner": {"id": row.creator.id if row.creator else None, "username": row.creator.username if row.creator else None},
|
||||
"items_count": len(row.items),
|
||||
"items": [{"name": item.name, "quantity": item.quantity, "note": item.note} for item in row.items],
|
||||
} for row in rows],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/share/<token>")
|
||||
# @app.route("/guest-list/<int:list_id>")
|
||||
@app.route("/shared/<int:list_id>")
|
||||
def shared_list(token=None, list_id=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if token:
|
||||
shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404()
|
||||
|
||||
expires_at = shopping_list.expires_at
|
||||
if expires_at and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# jeśli lista wygasła – zablokuj (spójne z resztą aplikacji)
|
||||
if shopping_list.is_temporary and expires_at and expires_at <= now:
|
||||
flash("Link wygasł.", "warning")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
list_id = shopping_list.id
|
||||
|
||||
# jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie
|
||||
if current_user.is_authenticated and current_user.id != shopping_list.owner_id:
|
||||
exists = (
|
||||
db.session.query(ListPermission.id)
|
||||
.filter(
|
||||
ListPermission.list_id == shopping_list.id,
|
||||
ListPermission.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not exists:
|
||||
db.session.add(
|
||||
ListPermission(list_id=shopping_list.id, user_id=current_user.id)
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
shopping_list = ShoppingList.query.get_or_404(list_id)
|
||||
expires_at = shopping_list.expires_at
|
||||
if expires_at and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if shopping_list.is_temporary and expires_at and expires_at <= now:
|
||||
flash("Ta lista wygasła.", "warning")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
is_allowed = shopping_list.is_public
|
||||
if current_user.is_authenticated:
|
||||
is_allowed = is_allowed or shopping_list.owner_id == current_user.id or (
|
||||
db.session.query(ListPermission.id)
|
||||
.filter(
|
||||
ListPermission.list_id == shopping_list.id,
|
||||
ListPermission.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
if not is_allowed:
|
||||
flash("Ta lista nie jest publicznie dostępna.", "warning")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
total_expense = get_total_expense_for_list(list_id)
|
||||
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
|
||||
|
||||
shopping_list.category_badges = [
|
||||
{"name": c.name, "color": category_color_for(c)}
|
||||
for c in shopping_list.categories
|
||||
]
|
||||
|
||||
for item in items:
|
||||
if item.added_by != shopping_list.owner_id:
|
||||
item.added_by_display = (
|
||||
item.added_by_user.username if item.added_by_user else "?"
|
||||
)
|
||||
else:
|
||||
item.added_by_display = None
|
||||
|
||||
return render_template(
|
||||
"list_share.html",
|
||||
list=shopping_list,
|
||||
items=items,
|
||||
receipts=receipts,
|
||||
expenses=expenses,
|
||||
total_expense=total_expense,
|
||||
is_share=True,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/copy/<int:list_id>")
|
||||
@login_required
|
||||
def copy_list(list_id):
|
||||
original = ShoppingList.query.get_or_404(list_id)
|
||||
token = generate_share_token(8)
|
||||
new_list = ShoppingList(
|
||||
title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token
|
||||
)
|
||||
db.session.add(new_list)
|
||||
db.session.commit()
|
||||
original_items = Item.query.filter_by(list_id=original.id).all()
|
||||
for item in original_items:
|
||||
copy_item = Item(list_id=new_list.id, name=item.name)
|
||||
db.session.add(copy_item)
|
||||
db.session.commit()
|
||||
flash("Skopiowano listę", "success")
|
||||
return redirect(url_for("view_list", list_id=new_list.id))
|
||||
|
||||
|
||||
@app.route("/suggest_products")
|
||||
def suggest_products():
|
||||
query = request.args.get("q", "")
|
||||
suggestions = []
|
||||
if query:
|
||||
suggestions = (
|
||||
SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%"))
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
return {"suggestions": [s.name for s in suggestions]}
|
||||
|
||||
|
||||
@app.route("/all_products")
|
||||
def all_products():
|
||||
sort = request.args.get("sort", "popularity")
|
||||
limit = request.args.get("limit", type=int) or 100
|
||||
offset = request.args.get("offset", type=int) or 0
|
||||
|
||||
products_from_items = db.session.query(
|
||||
func.lower(func.trim(Item.name)).label("normalized_name"),
|
||||
func.min(Item.name).label("display_name"),
|
||||
func.count(func.distinct(Item.list_id)).label("count"),
|
||||
).group_by(func.lower(func.trim(Item.name)))
|
||||
|
||||
products_from_suggested = (
|
||||
db.session.query(
|
||||
func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"),
|
||||
func.min(SuggestedProduct.name).label("display_name"),
|
||||
db.literal(1).label("count"),
|
||||
)
|
||||
.filter(
|
||||
~func.lower(func.trim(SuggestedProduct.name)).in_(
|
||||
db.session.query(func.lower(func.trim(Item.name)))
|
||||
)
|
||||
)
|
||||
.group_by(func.lower(func.trim(SuggestedProduct.name)))
|
||||
)
|
||||
|
||||
union_q = products_from_items.union_all(products_from_suggested).subquery()
|
||||
|
||||
final_q = db.session.query(
|
||||
union_q.c.normalized_name,
|
||||
union_q.c.display_name,
|
||||
func.sum(union_q.c.count).label("count"),
|
||||
).group_by(union_q.c.normalized_name, union_q.c.display_name)
|
||||
|
||||
if sort == "alphabetical":
|
||||
final_q = final_q.order_by(func.lower(union_q.c.display_name).asc())
|
||||
else:
|
||||
final_q = final_q.order_by(
|
||||
func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc()
|
||||
)
|
||||
|
||||
total_count = (
|
||||
db.session.query(func.count()).select_from(final_q.subquery()).scalar()
|
||||
)
|
||||
products = final_q.offset(offset).limit(limit).all()
|
||||
|
||||
out = [{"name": row.display_name, "count": row.count} for row in products]
|
||||
|
||||
return jsonify({"products": out, "total_count": total_count})
|
||||
|
||||
|
||||
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
|
||||
@login_required
|
||||
def upload_receipt(list_id):
|
||||
l = db.session.get(ShoppingList, list_id)
|
||||
|
||||
file = request.files.get("receipt")
|
||||
if not file or file.filename == "":
|
||||
return receipt_error("Nie wybrano pliku")
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
return receipt_error("Niedozwolony format pliku")
|
||||
|
||||
file_bytes = file.read()
|
||||
file.seek(0)
|
||||
file_hash = hashlib.sha256(file_bytes).hexdigest()
|
||||
|
||||
existing = Receipt.query.filter_by(file_hash=file_hash).first()
|
||||
if existing:
|
||||
return receipt_error("Taki plik już istnieje")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
timestamp = now.strftime("%Y%m%d_%H%M")
|
||||
random_part = secrets.token_hex(3)
|
||||
webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp"
|
||||
file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename)
|
||||
|
||||
try:
|
||||
if file.filename.lower().endswith(".pdf"):
|
||||
file.seek(0)
|
||||
save_pdf_as_webp(file, file_path)
|
||||
else:
|
||||
save_resized_image(file, file_path)
|
||||
except ValueError as e:
|
||||
return receipt_error(str(e))
|
||||
|
||||
try:
|
||||
new_receipt = Receipt(
|
||||
list_id=list_id,
|
||||
filename=webp_filename,
|
||||
filesize=os.path.getsize(file_path),
|
||||
uploaded_at=now,
|
||||
file_hash=file_hash,
|
||||
uploaded_by=current_user.id,
|
||||
version_token=generate_version_token(),
|
||||
)
|
||||
db.session.add(new_receipt)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
return receipt_error(f"Błąd zapisu do bazy: {str(e)}")
|
||||
|
||||
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
url = (
|
||||
url_for("uploaded_file", filename=webp_filename)
|
||||
+ f"?v={new_receipt.version_token or '0'}"
|
||||
)
|
||||
socketio.emit("receipt_added", {"url": url}, to=str(list_id))
|
||||
return jsonify({"success": True, "url": url})
|
||||
|
||||
flash("Wgrano paragon", "success")
|
||||
return redirect(request.referrer or url_for("main_page"))
|
||||
|
||||
|
||||
@app.route("/uploads/<filename>")
|
||||
def uploaded_file(filename):
|
||||
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||
response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"]
|
||||
response.headers.pop("Content-Disposition", None)
|
||||
mime, _ = mimetypes.guess_type(filename)
|
||||
if mime:
|
||||
response.headers["Content-Type"] = mime
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/reorder_items", methods=["POST"])
|
||||
@login_required
|
||||
def reorder_items():
|
||||
data = request.get_json()
|
||||
list_id = data.get("list_id")
|
||||
order = data.get("order")
|
||||
|
||||
for index, item_id in enumerate(order):
|
||||
item = db.session.get(Item, item_id)
|
||||
if item and item.list_id == list_id:
|
||||
item.position = index
|
||||
db.session.commit()
|
||||
|
||||
socketio.emit(
|
||||
"items_reordered", {"list_id": list_id, "order": order}, to=str(list_id)
|
||||
)
|
||||
|
||||
return jsonify(success=True)
|
||||
|
||||
|
||||
@app.route("/rotate_receipt/<int:receipt_id>")
|
||||
@login_required
|
||||
def rotate_receipt_user(receipt_id):
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
|
||||
|
||||
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
|
||||
flash("Brak uprawnień do tej operacji", "danger")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
try:
|
||||
rotate_receipt_by_id(receipt_id)
|
||||
recalculate_filesizes(receipt_id)
|
||||
flash("Obrócono paragon", "success")
|
||||
except FileNotFoundError:
|
||||
flash("Plik nie istnieje", "danger")
|
||||
except Exception as e:
|
||||
flash(f"Błąd przy obracaniu: {str(e)}", "danger")
|
||||
|
||||
return redirect(request.referrer or url_for("main_page"))
|
||||
|
||||
|
||||
@app.route("/delete_receipt/<int:receipt_id>")
|
||||
@login_required
|
||||
def delete_receipt_user(receipt_id):
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
|
||||
|
||||
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
|
||||
flash("Brak uprawnień do tej operacji", "danger")
|
||||
return redirect(url_for("main_page"))
|
||||
|
||||
try:
|
||||
delete_receipt_by_id(receipt_id)
|
||||
flash("Paragon usunięty", "success")
|
||||
except Exception as e:
|
||||
flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger")
|
||||
|
||||
return redirect(request.referrer or url_for("main_page"))
|
||||
|
||||
|
||||
# OCR
|
||||
@app.route("/lists/<int:list_id>/analyze", methods=["POST"])
|
||||
@login_required
|
||||
def analyze_receipts_for_list(list_id):
|
||||
receipt_objs = Receipt.query.filter_by(list_id=list_id).all()
|
||||
existing_expenses = {
|
||||
e.receipt_filename
|
||||
for e in Expense.query.filter_by(list_id=list_id).all()
|
||||
if e.receipt_filename
|
||||
}
|
||||
|
||||
results = []
|
||||
total = 0.0
|
||||
|
||||
for receipt in receipt_objs:
|
||||
filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
|
||||
if not os.path.exists(filepath):
|
||||
continue
|
||||
|
||||
try:
|
||||
raw_image = Image.open(filepath).convert("RGB")
|
||||
image = preprocess_image_for_tesseract(raw_image)
|
||||
value, lines = extract_total_tesseract(image)
|
||||
|
||||
except Exception as e:
|
||||
print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}")
|
||||
value = 0.0
|
||||
lines = []
|
||||
|
||||
already_added = receipt.filename in existing_expenses
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": receipt.id,
|
||||
"filename": receipt.filename,
|
||||
"amount": round(value, 2),
|
||||
"debug_text": lines,
|
||||
"already_added": already_added,
|
||||
}
|
||||
)
|
||||
|
||||
# if not already_added:
|
||||
total += value
|
||||
|
||||
return jsonify({"results": results, "total": round(total, 2)})
|
||||
|
||||
|
||||
@app.route("/user_crop_receipt", methods=["POST"])
|
||||
@login_required
|
||||
def crop_receipt_user():
|
||||
receipt_id = request.form.get("receipt_id")
|
||||
file = request.files.get("cropped_image")
|
||||
|
||||
receipt = Receipt.query.get_or_404(receipt_id)
|
||||
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
|
||||
|
||||
if list_obj.owner_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify(success=False, error="Brak dostępu"), 403
|
||||
|
||||
result = handle_crop_receipt(receipt_id, file)
|
||||
return jsonify(result)
|
||||
|
||||
618
shopping_app/sockets.py
Normal file
@@ -0,0 +1,618 @@
|
||||
import click
|
||||
from .deps import *
|
||||
from .app_setup import *
|
||||
from .models import *
|
||||
from .helpers import *
|
||||
|
||||
from flask import render_template_string
|
||||
|
||||
@app.route('/admin/debug-socket')
|
||||
@login_required
|
||||
@admin_required
|
||||
def debug_socket():
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Socket Debug</title>
|
||||
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1e1e1e; color: #fff; padding: 20px; }
|
||||
#log { height: 400px; overflow-y: scroll; background: #2d2d2d; padding: 15px; border-radius: 8px; margin: 10px 0; white-space: pre-wrap; }
|
||||
button { background: #007bff; color: white; border: none; padding: 10px 20px; margin: 5px; border-radius: 5px; cursor: pointer; }
|
||||
button:hover { background: #0056b3; }
|
||||
.status { font-size: 18px; font-weight: bold; margin: 10px 0; }
|
||||
.connected { color: #28a745; }
|
||||
.disconnected { color: #dc3545; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Socket.IO Debug Tool</h1>
|
||||
|
||||
<div id="status" class="status disconnected">Rozlaczony</div>
|
||||
<div id="info">
|
||||
Transport: <span id="transport">-</span> |
|
||||
Ping: <span id="ping">-</span>ms |
|
||||
SID: <span id="sid">-</span>
|
||||
</div>
|
||||
|
||||
<button onclick="connect()">Polacz</button>
|
||||
<button onclick="disconnect()">Rozlacz</button>
|
||||
<button onclick="emitTest()">Emit Test</button>
|
||||
<button onclick="forcePolling()">Force Polling</button>
|
||||
|
||||
<h3>Logi:</h3>
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
let socket;
|
||||
let logLines = 0;
|
||||
let isPollingOnly = true;
|
||||
|
||||
function log(msg, color = '#fff') {
|
||||
const logEl = document.getElementById('log');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
logEl.innerHTML += `[${time}] ${msg}\n`;
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
logLines++;
|
||||
if (logLines > 200) {
|
||||
const lines = logEl.innerHTML.split('\\n');
|
||||
logEl.innerHTML = lines.slice(-200).join('\\n');
|
||||
logLines = 200;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(connected) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = connected ? 'Polaczony' : 'Rozlaczony';
|
||||
status.className = `status ${connected ? 'connected' : 'disconnected'}`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
|
||||
const transports = isPollingOnly ? ['polling'] : ['polling', 'websocket'];
|
||||
log(`Polaczenie z: ${transports.join(', ')}`);
|
||||
|
||||
socket = io('', {
|
||||
transports: transports,
|
||||
timeout: 20000,
|
||||
autoConnect: false,
|
||||
forceNew: true
|
||||
});
|
||||
|
||||
socket.on('connect', function() {
|
||||
log('CONNECTED OK');
|
||||
updateStatus(true);
|
||||
try {
|
||||
const transport = socket.io.engine.transport.name;
|
||||
document.getElementById('transport').textContent = transport;
|
||||
document.getElementById('sid').textContent = socket.id.substring(0,8) + '...';
|
||||
} catch(e) {
|
||||
log('Transport info error: ' + e.message);
|
||||
}
|
||||
socket.emit('requestfulllist', {listid: 1});
|
||||
});
|
||||
|
||||
socket.on('disconnect', function(reason) {
|
||||
log('DISCONNECTED: ' + reason);
|
||||
updateStatus(false);
|
||||
});
|
||||
|
||||
socket.on('connect_error', function(err) {
|
||||
log('CONNECT ERROR: ' + err.message + ' (' + (err.type || 'unknown') + ')');
|
||||
});
|
||||
|
||||
socket.onAny(function(event, ...args) {
|
||||
log('RECV ' + event + ': ' + JSON.stringify(args).substring(0,100));
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
function emitTest() {
|
||||
if (!socket || !socket.connected) {
|
||||
log('Niepolaczony!');
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
socket.emit('pingtest', now);
|
||||
log('SENT pingtest ' + now);
|
||||
}
|
||||
|
||||
function forcePolling() {
|
||||
isPollingOnly = !isPollingOnly;
|
||||
log('Polling only: ' + isPollingOnly);
|
||||
connect();
|
||||
}
|
||||
|
||||
// STATUS check co 30s
|
||||
setInterval(function() {
|
||||
if (socket && socket.connected) {
|
||||
const transport = socket.io.engine ? socket.io.engine.transport.name : 'unknown';
|
||||
log('STATUS OK: ' + transport + ' | SID: ' + (socket.id ? socket.id.substring(0,8) : 'none'));
|
||||
emitTest();
|
||||
} else {
|
||||
log('STATUS: Offline');
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Start
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''')
|
||||
|
||||
|
||||
|
||||
# =========================================================================================
|
||||
# SOCKET.IO
|
||||
# =========================================================================================
|
||||
|
||||
|
||||
@socketio.on("delete_item")
|
||||
def handle_delete_item(data):
|
||||
# item = Item.query.get(data["item_id"])
|
||||
item = db.session.get(Item, data["item_id"])
|
||||
|
||||
if item:
|
||||
list_id = item.list_id
|
||||
log_list_activity(list_id, 'item_deleted', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
emit("item_deleted", {"item_id": item.id}, to=str(item.list_id))
|
||||
|
||||
purchased_count, total_count, percent = get_progress(list_id)
|
||||
|
||||
emit(
|
||||
"progress_updated",
|
||||
{
|
||||
"purchased_count": purchased_count,
|
||||
"total_count": total_count,
|
||||
"percent": percent,
|
||||
},
|
||||
to=str(list_id),
|
||||
)
|
||||
|
||||
|
||||
@socketio.on("edit_item")
|
||||
def handle_edit_item(data):
|
||||
item = db.session.get(Item, data["item_id"])
|
||||
|
||||
new_name = data["new_name"]
|
||||
new_quantity = data.get("new_quantity", item.quantity)
|
||||
|
||||
if item and new_name.strip():
|
||||
item.name = new_name.strip()
|
||||
|
||||
try:
|
||||
new_quantity = int(new_quantity)
|
||||
if new_quantity < 1:
|
||||
new_quantity = 1
|
||||
except:
|
||||
new_quantity = 1
|
||||
|
||||
item.quantity = new_quantity
|
||||
|
||||
db.session.commit()
|
||||
|
||||
emit(
|
||||
"item_edited",
|
||||
{"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity},
|
||||
to=str(item.list_id),
|
||||
)
|
||||
|
||||
|
||||
@socketio.on("join_list")
|
||||
def handle_join(data):
|
||||
global active_users
|
||||
room = str(data["room"])
|
||||
username = data.get("username", "Gość")
|
||||
join_room(room)
|
||||
|
||||
if room not in active_users:
|
||||
active_users[room] = set()
|
||||
active_users[room].add(username)
|
||||
|
||||
shopping_list = db.session.get(ShoppingList, int(data["room"]))
|
||||
|
||||
list_title = shopping_list.title if shopping_list else "Twoja lista"
|
||||
|
||||
emit("user_joined", {"username": username}, to=room)
|
||||
emit("user_list", {"users": list(active_users[room])}, to=room)
|
||||
emit("joined_confirmation", {"room": room, "list_title": list_title})
|
||||
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect(sid):
|
||||
global active_users
|
||||
username = current_user.username if current_user.is_authenticated else "Gość"
|
||||
for room, users in active_users.items():
|
||||
if username in users:
|
||||
users.remove(username)
|
||||
emit("user_left", {"username": username}, to=room)
|
||||
emit("user_list", {"users": list(users)}, to=room)
|
||||
|
||||
|
||||
@socketio.on("add_item")
|
||||
def handle_add_item(data):
|
||||
list_id = data["list_id"]
|
||||
name = data["name"].strip()
|
||||
quantity = data.get("quantity", 1)
|
||||
|
||||
list_obj = db.session.get(ShoppingList, list_id)
|
||||
if not list_obj:
|
||||
return
|
||||
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
if quantity < 1:
|
||||
quantity = 1
|
||||
except:
|
||||
quantity = 1
|
||||
|
||||
existing_item = Item.query.filter(
|
||||
Item.list_id == list_id,
|
||||
func.lower(Item.name) == name.lower(),
|
||||
Item.not_purchased == False,
|
||||
).first()
|
||||
|
||||
if existing_item:
|
||||
existing_item.quantity += quantity
|
||||
db.session.commit()
|
||||
|
||||
emit(
|
||||
"item_edited",
|
||||
{
|
||||
"item_id": existing_item.id,
|
||||
"new_name": existing_item.name,
|
||||
"new_quantity": existing_item.quantity,
|
||||
},
|
||||
to=str(list_id),
|
||||
)
|
||||
else:
|
||||
max_position = (
|
||||
db.session.query(func.max(Item.position))
|
||||
.filter_by(list_id=list_id)
|
||||
.scalar()
|
||||
)
|
||||
if max_position is None:
|
||||
max_position = 0
|
||||
|
||||
user_id = current_user.id if current_user.is_authenticated else None
|
||||
user_name = current_user.username if current_user.is_authenticated else "Gość"
|
||||
|
||||
new_item = Item(
|
||||
list_id=list_id,
|
||||
name=name,
|
||||
quantity=quantity,
|
||||
position=max_position + 1,
|
||||
added_by=user_id,
|
||||
)
|
||||
db.session.add(new_item)
|
||||
|
||||
if not SuggestedProduct.query.filter(
|
||||
func.lower(SuggestedProduct.name) == name.lower()
|
||||
).first():
|
||||
new_suggestion = SuggestedProduct(name=name)
|
||||
db.session.add(new_suggestion)
|
||||
|
||||
log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {new_item.quantity}')
|
||||
db.session.commit()
|
||||
|
||||
emit(
|
||||
"item_added",
|
||||
{
|
||||
"id": new_item.id,
|
||||
"name": new_item.name,
|
||||
"quantity": new_item.quantity,
|
||||
"added_by": user_name,
|
||||
"added_by_id": user_id,
|
||||
"owner_id": list_obj.owner_id,
|
||||
},
|
||||
to=str(list_id),
|
||||
include_self=True,
|
||||
)
|
||||
|
||||
purchased_count, total_count, percent = get_progress(list_id)
|
||||
|
||||
emit(
|
||||
"progress_updated",
|
||||
{
|
||||
"purchased_count": purchased_count,
|
||||
"total_count": total_count,
|
||||
"percent": percent,
|
||||
},
|
||||
to=str(list_id),
|
||||
)
|
||||
|
||||
|
||||
@socketio.on("check_item")
|
||||
def handle_check_item(data):
|
||||
item = db.session.get(Item, data["item_id"])
|
||||
|
||||
if item:
|
||||
item.purchased = True
|
||||
item.purchased_at = datetime.now(UTC)
|
||||
log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
|
||||
db.session.commit()
|
||||
|
||||
purchased_count, total_count, percent = get_progress(item.list_id)
|
||||
|
||||
emit("item_checked", {"item_id": item.id}, to=str(item.list_id))
|
||||
emit(
|
||||
"progress_updated",
|
||||
{
|
||||
"purchased_count": purchased_count,
|
||||
"total_count": total_count,
|
||||
"percent": percent,
|
||||
},
|
||||
to=str(item.list_id),
|
||||
)
|
||||
|
||||
|
||||
@socketio.on("uncheck_item")
|
||||
def handle_uncheck_item(data):
|
||||
item = db.session.get(Item, data["item_id"])
|
||||
|
||||
if item:
|
||||
item.purchased = False
|
||||
item.purchased_at = None
|
||||
log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
|
||||
db.session.commit()
|
||||
|
||||
purchased_count, total_count, percent = get_progress(item.list_id)
|
||||
|
||||
emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id))
|
||||
emit(
|
||||
"progress_updated",
|
||||
{
|
||||
"purchased_count": purchased_count,
|
||||
"total_count": total_count,
|
||||
"percent": percent,
|
||||
},
|
||||
to=str(item.list_id),
|
||||
)
|
||||
|
||||
|
||||
@socketio.on("request_full_list")
|
||||
def handle_request_full_list(data):
|
||||
list_id = data["list_id"]
|
||||
|
||||
shopping_list = db.session.get(ShoppingList, list_id)
|
||||
if not shopping_list:
|
||||
return
|
||||
|
||||
owner_id = shopping_list.owner_id
|
||||
|
||||
items = (
|
||||
Item.query.options(joinedload(Item.added_by_user))
|
||||
.filter_by(list_id=list_id)
|
||||
.order_by(Item.position.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
items_data = []
|
||||
for item in items:
|
||||
items_data.append(
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"quantity": item.quantity,
|
||||
"purchased": item.purchased if not item.not_purchased else False,
|
||||
"not_purchased": item.not_purchased,
|
||||
"not_purchased_reason": item.not_purchased_reason,
|
||||
"note": item.note or "",
|
||||
"added_by": item.added_by_user.username if item.added_by_user else None,
|
||||
"added_by_id": item.added_by_user.id if item.added_by_user else None,
|
||||
"owner_id": owner_id,
|
||||
}
|
||||
)
|
||||
|
||||
emit("full_list", {"items": items_data}, to=request.sid)
|
||||
|
||||
|
||||
@socketio.on("update_note")
|
||||
def handle_update_note(data):
|
||||
item_id = data["item_id"]
|
||||
note = data["note"]
|
||||
item = Item.query.get(item_id)
|
||||
if item:
|
||||
item.note = note
|
||||
db.session.commit()
|
||||
emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id))
|
||||
|
||||
|
||||
@socketio.on("add_expense")
|
||||
def handle_add_expense(data):
|
||||
list_id = data["list_id"]
|
||||
amount = data["amount"]
|
||||
receipt_filename = data.get("receipt_filename")
|
||||
|
||||
if receipt_filename:
|
||||
existing = Expense.query.filter_by(
|
||||
list_id=list_id, receipt_filename=receipt_filename
|
||||
).first()
|
||||
if existing:
|
||||
return
|
||||
new_expense = Expense(
|
||||
list_id=list_id, amount=amount, receipt_filename=receipt_filename
|
||||
)
|
||||
|
||||
db.session.add(new_expense)
|
||||
log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {float(amount):.2f} PLN')
|
||||
db.session.commit()
|
||||
|
||||
total = (
|
||||
db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
emit("expense_added", {"amount": amount, "total": total}, to=str(list_id))
|
||||
|
||||
|
||||
@socketio.on("mark_not_purchased")
|
||||
def handle_mark_not_purchased(data):
|
||||
item = db.session.get(Item, data["item_id"])
|
||||
|
||||
reason = data.get("reason", "")
|
||||
if item:
|
||||
item.not_purchased = True
|
||||
item.not_purchased_reason = reason
|
||||
log_list_activity(item.list_id, 'item_marked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=reason or None)
|
||||
db.session.commit()
|
||||
emit(
|
||||
"item_marked_not_purchased",
|
||||
{"item_id": item.id, "reason": reason},
|
||||
to=str(item.list_id),
|
||||
)
|
||||
|
||||
|
||||
@socketio.on("unmark_not_purchased")
|
||||
def handle_unmark_not_purchased(data):
|
||||
item = db.session.get(Item, data["item_id"])
|
||||
|
||||
if item:
|
||||
item.not_purchased = False
|
||||
item.purchased = False
|
||||
item.purchased_at = None
|
||||
item.not_purchased_reason = None
|
||||
log_list_activity(item.list_id, 'item_unmarked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
|
||||
db.session.commit()
|
||||
emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id))
|
||||
|
||||
|
||||
@app.cli.command("db_info")
|
||||
def create_db():
|
||||
with app.app_context():
|
||||
inspector = inspect(db.engine)
|
||||
actual_tables = inspector.get_table_names()
|
||||
|
||||
table_count = len(actual_tables)
|
||||
record_total = 0
|
||||
with db.engine.connect() as conn:
|
||||
for table in actual_tables:
|
||||
try:
|
||||
count = conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar()
|
||||
record_total += count
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\nStruktura bazy danych jest poprawna.")
|
||||
print(f"Silnik: {db.engine.name}")
|
||||
print(f"Liczba tabel: {table_count}")
|
||||
print(f"Łączna liczba rekordów: {record_total}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO)
|
||||
socketio.run(app, host="0.0.0.0", port=APP_PORT, debug=False)
|
||||
|
||||
|
||||
@app.cli.group("admins")
|
||||
def admins_cli():
|
||||
"""Zarzadzanie kontami administratorow z CLI."""
|
||||
|
||||
|
||||
@admins_cli.command("list")
|
||||
def admins_list_command():
|
||||
with app.app_context():
|
||||
users = User.query.order_by(User.username.asc()).all()
|
||||
if not users:
|
||||
click.echo('Brak uzytkownikow.')
|
||||
return
|
||||
for user in users:
|
||||
role = 'admin' if user.is_admin else 'user'
|
||||
click.echo(f"{user.id} {user.username} {role}")
|
||||
|
||||
|
||||
@admins_cli.command("create")
|
||||
@click.argument("username")
|
||||
@click.argument("password")
|
||||
@click.option("--admin/--user", "make_admin", default=True, show_default=True, help="Utworz konto admina albo zwyklego uzytkownika.")
|
||||
def admins_create_command(username, password, make_admin):
|
||||
with app.app_context():
|
||||
user, created, _ = create_or_update_admin_user(username, password=password, make_admin=make_admin, update_password=False)
|
||||
status = 'Utworzono' if created else 'Istnieje juz'
|
||||
click.echo(f"{status} konto: id={user.id}, username={user.username}, admin={user.is_admin}")
|
||||
|
||||
|
||||
@admins_cli.command("promote")
|
||||
@click.argument("username")
|
||||
def admins_promote_command(username):
|
||||
with app.app_context():
|
||||
user = resolve_user_identifier(username)
|
||||
if not user:
|
||||
raise click.ClickException('Nie znaleziono uzytkownika.')
|
||||
user.is_admin = True
|
||||
db.session.commit()
|
||||
click.echo(f"Uzytkownik {user.username} ma teraz uprawnienia admina.")
|
||||
|
||||
|
||||
@admins_cli.command("demote")
|
||||
@click.argument("username")
|
||||
def admins_demote_command(username):
|
||||
with app.app_context():
|
||||
user = resolve_user_identifier(username)
|
||||
if not user:
|
||||
raise click.ClickException('Nie znaleziono uzytkownika.')
|
||||
user.is_admin = False
|
||||
db.session.commit()
|
||||
click.echo(f"Uzytkownik {user.username} nie jest juz adminem.")
|
||||
|
||||
|
||||
@admins_cli.command("set-password")
|
||||
@click.argument("username")
|
||||
@click.argument("password")
|
||||
def admins_set_password_command(username, password):
|
||||
with app.app_context():
|
||||
user = resolve_user_identifier(username)
|
||||
if not user:
|
||||
raise click.ClickException('Nie znaleziono uzytkownika.')
|
||||
user.password_hash = hash_password(password)
|
||||
db.session.commit()
|
||||
click.echo(f"Zmieniono haslo dla {user.username}.")
|
||||
|
||||
|
||||
@app.cli.group("lists")
|
||||
def lists_cli():
|
||||
"""Operacje CLI na listach zakupowych."""
|
||||
|
||||
|
||||
@lists_cli.command("copy-schedule")
|
||||
@click.option("--source-list-id", required=True, type=int, help="ID listy zrodlowej.")
|
||||
@click.option("--when", "when_value", required=True, help="Nowa data utworzenia listy: YYYY-MM-DD lub YYYY-MM-DD HH:MM")
|
||||
@click.option("--owner", "owner_value", default=None, help="Nowy wlasciciel: username albo ID. Domyslnie wlasciciel oryginalu.")
|
||||
@click.option("--title", default=None, help="Nowy tytul listy. Domyslnie taki sam jak w oryginale.")
|
||||
def lists_copy_schedule_command(source_list_id, when_value, owner_value, title):
|
||||
with app.app_context():
|
||||
source_list = ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories)).get(source_list_id)
|
||||
if not source_list:
|
||||
raise click.ClickException('Nie znaleziono listy zrodlowej.')
|
||||
|
||||
try:
|
||||
scheduled_for = parse_cli_datetime(when_value)
|
||||
except ValueError as exc:
|
||||
raise click.ClickException(str(exc))
|
||||
|
||||
owner = None
|
||||
if owner_value:
|
||||
owner = resolve_user_identifier(owner_value)
|
||||
if not owner:
|
||||
raise click.ClickException('Nie znaleziono docelowego wlasciciela.')
|
||||
|
||||
new_list = duplicate_list_for_schedule(source_list, scheduled_for=scheduled_for, owner=owner, title=title)
|
||||
click.echo(
|
||||
f"Utworzono kopie listy: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}"
|
||||
)
|
||||
95
shopping_app/startup_info.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import socket
|
||||
from datetime import datetime
|
||||
|
||||
import psutil
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
except Exception:
|
||||
text = None
|
||||
|
||||
def mb(x):
|
||||
return int(x / 1024 / 1024)
|
||||
|
||||
|
||||
def get_db_type(app):
|
||||
uri = app.config.get("SQLALCHEMY_DATABASE_URI") or app.config.get("DATABASE_URL", "")
|
||||
|
||||
if not uri:
|
||||
return "NONE"
|
||||
|
||||
if uri.startswith("sqlite"):
|
||||
return "SQLite"
|
||||
if uri.startswith("mysql"):
|
||||
return "MySQL"
|
||||
if uri.startswith("postgresql"):
|
||||
return "PostgreSQL"
|
||||
|
||||
return "OTHER"
|
||||
|
||||
def print_startup_info(app):
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
port = int(os.getenv("PORT", "8000"))
|
||||
|
||||
rules = list(app.url_map.iter_rules())
|
||||
|
||||
cpu = psutil.cpu_percent(interval=0.2)
|
||||
ram = psutil.virtual_memory()
|
||||
proc = psutil.Process(os.getpid())
|
||||
|
||||
db_type = get_db_type(app)
|
||||
|
||||
print("\n" + "="*52)
|
||||
print(" APP START")
|
||||
print("="*52)
|
||||
|
||||
# SYSTEM
|
||||
print("\n[ SYSTEM ]")
|
||||
print(f"Time : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"OS : {platform.system()} {platform.release()} ({platform.machine()})")
|
||||
print(f"Python : {sys.version.split()[0]}")
|
||||
print(f"Host : {socket.gethostname()}")
|
||||
|
||||
# SERVER
|
||||
print("\n[ SERVER ]")
|
||||
print(f"Bind : {host}:{port}")
|
||||
print(f"URL : http://127.0.0.1:{port}")
|
||||
|
||||
# APP
|
||||
print("\n[ APP ]")
|
||||
print(f"Name : {app.name}")
|
||||
print(f"Mode : {'DEV' if app.debug else 'PROD'}")
|
||||
print(f"Debug : {app.debug}")
|
||||
|
||||
# RESOURCES
|
||||
print("\n[ RESOURCES ]")
|
||||
print(f"CPU : {cpu:>5.1f}%")
|
||||
print(f"RAM : {ram.percent:>5.1f}% ({mb(ram.used)} / {mb(ram.total)} MB)")
|
||||
print(f"PROC : {mb(proc.memory_info().rss)} MB")
|
||||
|
||||
# DATABASE
|
||||
print("\n[ DATABASE ]")
|
||||
print(f"Type : {db_type}")
|
||||
|
||||
# SECURITY
|
||||
print("\n[ SECURITY ]")
|
||||
print(f"Secret : {'OK' if app.config.get('SECRET_KEY') else 'MISSING'}")
|
||||
print(f"Talis : {'OFF' if app.config.get('TALISMAN_DISABLED') else 'ON'}")
|
||||
|
||||
# HEALTH
|
||||
print("\n[ HEALTH ]")
|
||||
print(f"Uploads: {'OK' if os.path.exists('uploads') else 'MISS'}")
|
||||
print(f"Static : {'OK' if os.path.exists(app.static_folder) else 'MISS'}")
|
||||
|
||||
# ROUTES
|
||||
print("\n[ ROUTES ]")
|
||||
print(f"Total : {len(rules)}")
|
||||
|
||||
# STATUS
|
||||
print("\n[ STATUS ]")
|
||||
print("READY")
|
||||
|
||||
print("="*52 + "\n")
|
||||
5351
shopping_app/static/css/style.css
Normal file
416
shopping_app/static/css/style_old.css
Normal file
@@ -0,0 +1,416 @@
|
||||
/* --- Rozmiary i kursory --- */
|
||||
.large-checkbox {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.clickable-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- Kolory tła (nadpisane klasy Bootstrapa) --- */
|
||||
.bg-success {
|
||||
background-color: #1e7e34 !important;
|
||||
}
|
||||
|
||||
.btn-outline-light:hover {
|
||||
background-color: #ffc107 !important;
|
||||
color: #000 !important;
|
||||
border-color: #ffc107 !important;
|
||||
}
|
||||
|
||||
.progress-dark {
|
||||
background-color: #212529 !important;
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
border-radius: 0 !important;
|
||||
transition: width 0.4s ease, background-color 0.4s ease;
|
||||
}
|
||||
|
||||
.progress-bar:first-child {
|
||||
border-top-left-radius: 20px !important;
|
||||
border-bottom-left-radius: 20px !important;
|
||||
}
|
||||
|
||||
.progress-bar:last-child {
|
||||
border-top-right-radius: 20px !important;
|
||||
border-bottom-right-radius: 20px !important;
|
||||
}
|
||||
|
||||
|
||||
/* rodzic już ma position-relative */
|
||||
.progress-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
/* klikalne przyciski obok paska nie ucierpią */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-thin {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.item-not-checked {
|
||||
background-color: #2c2f33 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* --- Styl przycisku wyboru pliku --- */
|
||||
input[type="file"]::file-selector-button {
|
||||
background-color: #225d36;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
/* --- Ciemniejsze alerty Bootstrapa --- */
|
||||
.alert-success {
|
||||
background-color: #225d36 !important;
|
||||
color: #eaffea !important;
|
||||
border-color: #174428 !important;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #7a1f23 !important;
|
||||
color: #ffeaea !important;
|
||||
border-color: #531417 !important;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #1d3a4d !important;
|
||||
color: #eaf6ff !important;
|
||||
border-color: #152837 !important;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #665c1e !important;
|
||||
color: #fffbe5 !important;
|
||||
border-color: #4d4415 !important;
|
||||
}
|
||||
|
||||
/* Badge - kolory pasujące do ciemnych alertów */
|
||||
.badge.bg-success,
|
||||
.badge.text-bg-success {
|
||||
background-color: #225d36 !important;
|
||||
color: #eaffea !important;
|
||||
}
|
||||
|
||||
.badge.bg-danger,
|
||||
.badge.text-bg-danger {
|
||||
background-color: #7a1f23 !important;
|
||||
color: #ffeaea !important;
|
||||
}
|
||||
|
||||
.badge.bg-info,
|
||||
.badge.text-bg-info {
|
||||
background-color: #1d3a4d !important;
|
||||
color: #eaf6ff !important;
|
||||
}
|
||||
|
||||
.badge.bg-warning,
|
||||
.badge.text-bg-warning {
|
||||
background-color: #665c1e !important;
|
||||
color: #fffbe5 !important;
|
||||
}
|
||||
|
||||
.badge.bg-secondary,
|
||||
.badge.text-bg-secondary {
|
||||
background-color: #343a40 !important;
|
||||
color: #e2e3e5 !important;
|
||||
}
|
||||
|
||||
.badge.bg-primary,
|
||||
.badge.text-bg-primary {
|
||||
background-color: #184076 !important;
|
||||
color: #e6f0ff !important;
|
||||
}
|
||||
|
||||
.badge.bg-light,
|
||||
.badge.text-bg-light {
|
||||
background-color: #444950 !important;
|
||||
color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
.badge.bg-dark,
|
||||
.badge.text-bg-dark {
|
||||
background-color: #181a1b !important;
|
||||
color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
/* --- Styl dla własnych checkboxów --- */
|
||||
input[type="checkbox"].large-checkbox {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
input[type="checkbox"].large-checkbox::before {
|
||||
content: '✗';
|
||||
color: #dc3545;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
input[type="checkbox"].large-checkbox:checked::before {
|
||||
content: '✓';
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
input[type="checkbox"].large-checkbox:disabled::before {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type="checkbox"].large-checkbox:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#tempToggle {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
input.form-control {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.info-bar-fixed {
|
||||
width: 100%;
|
||||
color: #f8f9fa;
|
||||
background-color: #212529;
|
||||
border-radius: 12px 12px 0 0;
|
||||
text-align: center;
|
||||
padding: 10px 10px;
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
margin-top: 2rem;
|
||||
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-bar-fixed {
|
||||
position: static;
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 4px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.table-responsive table {
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
.bg-dark .form-control::placeholder {
|
||||
color: #ccc !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
color: #ffffff !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.toast {
|
||||
animation: fadeInUp 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
#mass-add-list li.active {
|
||||
background: #198754 !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid #000000 !important;
|
||||
}
|
||||
|
||||
#mass-add-list li {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.quantity-input {
|
||||
width: 60px;
|
||||
background: #343a40;
|
||||
color: #fff;
|
||||
border: 1px solid #495057;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.quantity-controls {
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#empty-placeholder {
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#items li.hide-purchased {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.list-group-item:first-child,
|
||||
.list-group-item:last-child {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
.only-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.ts-dropdown .active {
|
||||
background-color: #495057 !important;
|
||||
}
|
||||
|
||||
.pagination-dark .page-link {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border: 1px solid #495057;
|
||||
}
|
||||
|
||||
.pagination-dark .page-link:hover {
|
||||
background-color: #343a40;
|
||||
border-color: #6c757d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-dark .page-item.active .page-link {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination-dark .page-item.disabled .page-link {
|
||||
background-color: #2b3035;
|
||||
border-color: #495057;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.tom-dark .ts-control {
|
||||
background-color: #212529 !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid #495057 !important;
|
||||
border-radius: 0.375rem;
|
||||
min-height: 38px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tom-dark .ts-control .item {
|
||||
background-color: #343a40 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem;
|
||||
padding: 2px 8px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.ts-dropdown {
|
||||
background-color: #212529 !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid #495057;
|
||||
border-radius: 0.375rem;
|
||||
z-index: 9999 !important;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ts-dropdown .active {
|
||||
background-color: #495057 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
td select.tom-dark {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.table-dark.table-striped tbody tr:nth-of-type(odd) {
|
||||
background-color: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.table-dark tbody tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.table-dark thead th {
|
||||
background-color: #1c1f22;
|
||||
color: #e1e1e1;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #3a3f44;
|
||||
}
|
||||
|
||||
.table-dark td,
|
||||
.table-dark th {
|
||||
padding: 0.6rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
border-top: 1px solid #3a3f44;
|
||||
}
|
||||
|
||||
.card .table {
|
||||
border-radius: 0 !important;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
182
shopping_app/static/js/access_users.js
Normal file
@@ -0,0 +1,182 @@
|
||||
(function () {
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
|
||||
const toast = (m, t = 'info') => (window.showToast ? window.showToast(m, t) : console.log(`[${t}]`, m));
|
||||
|
||||
function appendToken(box, user) {
|
||||
const tokensBox = $('.tokens', box);
|
||||
if (!tokensBox || !user?.id || !user?.username) return;
|
||||
const empty = $('.no-perms', box);
|
||||
if (empty) empty.remove();
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-secondary rounded-pill token';
|
||||
btn.dataset.userId = user.id;
|
||||
btn.dataset.username = user.username;
|
||||
btn.title = 'Kliknij, aby odebrać dostęp';
|
||||
btn.innerHTML = `@${user.username} <span aria-hidden="true">×</span>`;
|
||||
tokensBox.appendChild(btn);
|
||||
}
|
||||
|
||||
function wantsJSON() {
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch'
|
||||
};
|
||||
}
|
||||
|
||||
async function postAction(postUrl, nextPath, params) {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(params)) form.set(k, v);
|
||||
form.set('next', nextPath);
|
||||
|
||||
try {
|
||||
const res = await fetch(postUrl, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'same-origin',
|
||||
headers: wantsJSON()
|
||||
});
|
||||
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: !!data?.ok, data, status: res.status };
|
||||
}
|
||||
return { ok: res.ok, data: null, status: res.status };
|
||||
} catch (e) {
|
||||
console.error('POST failed', e);
|
||||
return { ok: false, data: null, status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function initEditor(box) {
|
||||
if (!box || !box.classList?.contains('access-editor')) return;
|
||||
if (box.dataset._accessEditorInit === '1') return;
|
||||
box.dataset._accessEditorInit = '1';
|
||||
|
||||
const postUrl = box.dataset.postUrl || location.pathname;
|
||||
const nextPath = box.dataset.next || location.pathname;
|
||||
const suggestUrl = box.dataset.suggestUrl || '';
|
||||
const grantAction = box.dataset.grantAction || 'grant';
|
||||
const revokeField = box.dataset.revokeField || 'revoke_user_id';
|
||||
const listId = box.dataset.listId || '';
|
||||
|
||||
const tokensBox = $('.tokens', box);
|
||||
const input = $('.access-input', box);
|
||||
const addBtn = $('.access-add', box);
|
||||
|
||||
let datalist = null;
|
||||
const existingListId = input?.getAttribute('list');
|
||||
if (existingListId) datalist = document.getElementById(existingListId);
|
||||
if (!datalist) datalist = $('#userHintsGeneric');
|
||||
if (!datalist) {
|
||||
datalist = document.createElement('datalist');
|
||||
datalist.id = 'userHintsGeneric';
|
||||
document.body.appendChild(datalist);
|
||||
}
|
||||
input?.setAttribute('list', datalist.id);
|
||||
|
||||
const unique = (arr) => Array.from(new Set(arr));
|
||||
const parseUserText = (txt) => unique((txt || '').split(/[\s,;]+/g).map(s => s.trim().replace(/^@/, '').toLowerCase()).filter(Boolean));
|
||||
const debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
|
||||
|
||||
const initialOptions = Array.from(datalist.querySelectorAll('option')).map(o => o.value).filter(Boolean);
|
||||
const renderHints = (users = []) => {
|
||||
const merged = unique([...(users || []), ...initialOptions]).slice(0, 20);
|
||||
datalist.innerHTML = merged.map(u => `<option value="${u}"></option>`).join('');
|
||||
};
|
||||
renderHints(initialOptions);
|
||||
|
||||
let acCtrl = null;
|
||||
const fetchHints = debounce(async (q) => {
|
||||
if (!suggestUrl) return;
|
||||
try {
|
||||
acCtrl?.abort();
|
||||
acCtrl = new AbortController();
|
||||
const normalized = String(q || '').trim().replace(/^@/, '');
|
||||
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(normalized)}`, { credentials: 'same-origin', signal: acCtrl.signal });
|
||||
if (!res.ok) return renderHints([]);
|
||||
const data = await res.json().catch(() => ({ users: [] }));
|
||||
renderHints(data.users || []);
|
||||
} catch {
|
||||
renderHints(initialOptions);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
input?.addEventListener('focus', () => fetchHints(input.value));
|
||||
input?.addEventListener('input', () => fetchHints(input.value));
|
||||
|
||||
box.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.token');
|
||||
if (!btn || !box.contains(btn)) return;
|
||||
|
||||
const userId = btn.dataset.userId;
|
||||
const username = btn.dataset.username;
|
||||
if (!userId) return toast('Brak identyfikatora użytkownika.', 'danger');
|
||||
|
||||
btn.disabled = true; btn.classList.add('disabled');
|
||||
const res = await postAction(postUrl, nextPath, { action: 'revoke', target_list_id: listId, [revokeField]: userId });
|
||||
|
||||
if (res.ok) {
|
||||
btn.remove();
|
||||
if (!$$('.token', box).length && tokensBox) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'no-perms text-warning small';
|
||||
empty.textContent = 'Brak dodanych uprawnień.';
|
||||
tokensBox.appendChild(empty);
|
||||
}
|
||||
toast(`Odebrano dostęp: @${username}`, 'success');
|
||||
} else {
|
||||
btn.disabled = false; btn.classList.remove('disabled');
|
||||
toast(`Nie udało się odebrać dostępu @${username}`, 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
async function addUsers() {
|
||||
const users = parseUserText(input?.value);
|
||||
if (!users?.length) return toast('Podaj co najmniej jednego użytkownika', 'warning');
|
||||
|
||||
addBtn.disabled = true;
|
||||
const prevText = addBtn.textContent;
|
||||
addBtn.textContent = 'Dodaję…';
|
||||
|
||||
let okCount = 0, failCount = 0, appended = 0;
|
||||
|
||||
for (const u of users) {
|
||||
const res = await postAction(postUrl, nextPath, { action: grantAction, target_list_id: listId, grant_username: u });
|
||||
if (res.ok) {
|
||||
okCount++;
|
||||
if (res.data?.user) {
|
||||
appendToken(box, res.data.user);
|
||||
appended++;
|
||||
}
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
addBtn.disabled = false;
|
||||
addBtn.textContent = prevText;
|
||||
if (input) input.value = '';
|
||||
|
||||
if (okCount) toast(`Dodano dostęp: ${okCount} użytkownika`, 'success');
|
||||
if (failCount) toast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
|
||||
|
||||
if (okCount && appended === 0) {
|
||||
setTimeout(() => location.reload(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
addBtn?.addEventListener('click', addUsers);
|
||||
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addUsers(); } });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
$$('.access-editor').forEach(initEditor);
|
||||
});
|
||||
document.addEventListener('shown.bs.modal', (ev) => {
|
||||
$$('.access-editor', ev.target).forEach(initEditor);
|
||||
});
|
||||
})();
|
||||
114
shopping_app/static/js/admin_settings.js
Normal file
@@ -0,0 +1,114 @@
|
||||
(function () {
|
||||
const form = document.getElementById("settings-form");
|
||||
const resetAllBtn = document.getElementById("reset-all");
|
||||
if (!form) return;
|
||||
|
||||
function getCard(input) {
|
||||
return input.closest(".settings-category-card");
|
||||
}
|
||||
|
||||
function getAutoHex(input) {
|
||||
const autoHex = (input.dataset.auto || "").trim();
|
||||
return autoHex ? autoHex.toUpperCase() : "#000000";
|
||||
}
|
||||
|
||||
function setOverrideState(input, enabled) {
|
||||
const card = getCard(input);
|
||||
const flag = card?.querySelector('.override-enabled');
|
||||
const badge = card?.querySelector('[data-role="override-status"]');
|
||||
input.dataset.hasOverride = enabled ? "1" : "0";
|
||||
if (flag) flag.value = enabled ? "1" : "0";
|
||||
if (badge) {
|
||||
badge.textContent = enabled ? "Nadpisany" : "Domyślny";
|
||||
badge.classList.toggle('text-bg-info', enabled);
|
||||
badge.classList.toggle('text-bg-secondary', !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreview(input) {
|
||||
const card = getCard(input);
|
||||
if (!card) return;
|
||||
const hexAutoEl = card.querySelector('.hex-auto');
|
||||
const hexEffEl = card.querySelector('.hex-effective');
|
||||
const barAuto = card.querySelector('.bar[data-kind="auto"]');
|
||||
const barEff = card.querySelector('.bar[data-kind="effective"]');
|
||||
const autoHex = getAutoHex(input);
|
||||
const effectiveHex = ((input.value || autoHex).trim() || autoHex).toUpperCase();
|
||||
const hasOverride = input.dataset.hasOverride === '1';
|
||||
|
||||
if (barAuto) barAuto.style.backgroundColor = autoHex;
|
||||
if (hexAutoEl) hexAutoEl.textContent = autoHex;
|
||||
if (barEff) barEff.style.backgroundColor = effectiveHex;
|
||||
if (hexEffEl) hexEffEl.textContent = effectiveHex;
|
||||
setOverrideState(input, hasOverride);
|
||||
}
|
||||
|
||||
function applyDefaultVisual(input, keepOverride) {
|
||||
input.value = getAutoHex(input);
|
||||
setOverrideState(input, !!keepOverride);
|
||||
updatePreview(input);
|
||||
}
|
||||
|
||||
form.querySelectorAll('.use-default').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const input = form.querySelector(`#${btn.dataset.target}`);
|
||||
if (!input) return;
|
||||
applyDefaultVisual(input, true);
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('.reset-one').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const input = form.querySelector(`#${btn.dataset.target}`);
|
||||
if (!input) return;
|
||||
applyDefaultVisual(input, false);
|
||||
});
|
||||
});
|
||||
|
||||
resetAllBtn?.addEventListener('click', () => {
|
||||
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
|
||||
applyDefaultVisual(input, false);
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
|
||||
updatePreview(input);
|
||||
input.addEventListener('input', () => {
|
||||
setOverrideState(input, true);
|
||||
updatePreview(input);
|
||||
});
|
||||
input.addEventListener('change', () => {
|
||||
setOverrideState(input, true);
|
||||
updatePreview(input);
|
||||
});
|
||||
});
|
||||
|
||||
(function () {
|
||||
const slider = document.getElementById('ocr_sensitivity');
|
||||
const badge = document.getElementById('ocr_sens_badge');
|
||||
const value = document.getElementById('ocr_sens_value');
|
||||
if (!slider || !badge || !value) return;
|
||||
|
||||
function labelFor(v) {
|
||||
v = Number(v);
|
||||
if (v <= 3) return 'Niski';
|
||||
if (v <= 7) return 'Średni';
|
||||
return 'Wysoki';
|
||||
}
|
||||
function clsFor(v) {
|
||||
v = Number(v);
|
||||
if (v <= 3) return 'sens-low';
|
||||
if (v <= 7) return 'sens-mid';
|
||||
return 'sens-high';
|
||||
}
|
||||
function update() {
|
||||
value.textContent = `(${slider.value})`;
|
||||
badge.textContent = labelFor(slider.value);
|
||||
badge.classList.remove('sens-low', 'sens-mid', 'sens-high');
|
||||
badge.classList.add(clsFor(slider.value));
|
||||
}
|
||||
slider.addEventListener('input', update);
|
||||
slider.addEventListener('change', update);
|
||||
update();
|
||||
})();
|
||||
})();
|
||||
270
shopping_app/static/js/app_ui.js
Normal file
@@ -0,0 +1,270 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
enhancePasswordFields();
|
||||
observePasswordFields();
|
||||
enhanceSearchableTables();
|
||||
wireCopyButtons();
|
||||
wireUnsavedWarnings();
|
||||
enhanceMobileTables();
|
||||
wireAdminNavToggle();
|
||||
initResponsiveCategoryBadges();
|
||||
});
|
||||
|
||||
function initPasswordField(input) {
|
||||
if (!input || input.dataset.uiPasswordReady === '1') return;
|
||||
if (input.closest('[data-ui-skip-toggle="true"]')) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'ui-password-toggle';
|
||||
btn.setAttribute('aria-label', 'Pokaż lub ukryj hasło');
|
||||
btn.setAttribute('aria-pressed', 'false');
|
||||
btn.title = 'Pokaż hasło';
|
||||
btn.innerHTML = '<span aria-hidden="true">👁</span>';
|
||||
|
||||
const syncState = function () {
|
||||
const visible = input.type === 'text';
|
||||
btn.innerHTML = visible ? '<span aria-hidden="true">🙈</span>' : '<span aria-hidden="true">👁</span>';
|
||||
btn.classList.toggle('is-active', visible);
|
||||
btn.setAttribute('aria-pressed', visible ? 'true' : 'false');
|
||||
btn.title = visible ? 'Ukryj hasło' : 'Pokaż hasło';
|
||||
};
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
const selectionStart = input.selectionStart;
|
||||
const selectionEnd = input.selectionEnd;
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
syncState();
|
||||
input.focus({ preventScroll: true });
|
||||
if (typeof selectionStart === 'number' && typeof selectionEnd === 'number') {
|
||||
try {
|
||||
input.setSelectionRange(selectionStart, selectionEnd);
|
||||
} catch (err) {}
|
||||
}
|
||||
});
|
||||
|
||||
const parent = input.parentElement;
|
||||
if (parent && parent.classList.contains('input-group')) {
|
||||
parent.classList.add('ui-password-group');
|
||||
if (!parent.querySelector(':scope > .ui-password-toggle')) {
|
||||
parent.appendChild(btn);
|
||||
}
|
||||
} else {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'input-group ui-password-group';
|
||||
input.parentNode.insertBefore(wrapper, input);
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(btn);
|
||||
}
|
||||
|
||||
input.dataset.uiPasswordReady = '1';
|
||||
syncState();
|
||||
}
|
||||
|
||||
function enhancePasswordFields(root) {
|
||||
const scope = root && root.querySelectorAll ? root : document;
|
||||
if (scope.matches && scope.matches('input[type="password"]')) {
|
||||
initPasswordField(scope);
|
||||
}
|
||||
scope.querySelectorAll('input[type="password"]').forEach(initPasswordField);
|
||||
}
|
||||
|
||||
function observePasswordFields() {
|
||||
if (window.__uiPasswordObserverReady) return;
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
mutation.addedNodes.forEach(function (node) {
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
enhancePasswordFields(node);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
window.__uiPasswordObserverReady = true;
|
||||
}
|
||||
|
||||
function enhanceSearchableTables() {
|
||||
if (document.getElementById('search-table')) return;
|
||||
const tables = document.querySelectorAll('table.sortable, table[data-searchable="true"]');
|
||||
|
||||
tables.forEach(function (table, index) {
|
||||
if (table.dataset.uiSearchReady === '1') return;
|
||||
|
||||
const tbody = table.tBodies[0];
|
||||
if (!tbody) return;
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
if (rows.length < 6) return;
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'table-toolbar';
|
||||
toolbar.innerHTML = [
|
||||
'<div class="input-group input-group-sm table-toolbar__search">',
|
||||
' <span class="input-group-text">🔎</span>',
|
||||
' <input type="search" class="form-control" placeholder="Filtruj tabelę…" aria-label="Filtruj tabelę">',
|
||||
' <button type="button" class="btn btn-outline-light">Wyczyść</button>',
|
||||
'</div>',
|
||||
'<div class="table-toolbar__meta text-secondary small">',
|
||||
' <span class="table-toolbar__count"></span>',
|
||||
'</div>'
|
||||
].join('');
|
||||
|
||||
const input = toolbar.querySelector('input');
|
||||
const clearBtn = toolbar.querySelector('button');
|
||||
const count = toolbar.querySelector('.table-toolbar__count');
|
||||
|
||||
function updateTableFilter() {
|
||||
const query = (input.value || '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
rows.forEach(function (row) {
|
||||
const rowText = row.innerText.toLowerCase();
|
||||
const match = !query || rowText.includes(query);
|
||||
row.style.display = match ? '' : 'none';
|
||||
if (match) visible += 1;
|
||||
});
|
||||
count.textContent = 'Widoczne: ' + visible + ' / ' + rows.length;
|
||||
}
|
||||
|
||||
input.addEventListener('input', updateTableFilter);
|
||||
clearBtn.addEventListener('click', function () {
|
||||
input.value = '';
|
||||
updateTableFilter();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
const container = table.closest('.table-responsive') || table;
|
||||
container.parentNode.insertBefore(toolbar, container);
|
||||
updateTableFilter();
|
||||
table.dataset.uiSearchReady = '1';
|
||||
});
|
||||
}
|
||||
|
||||
function wireCopyButtons() {
|
||||
document.querySelectorAll('[data-copy-target]').forEach(function (button) {
|
||||
if (button.dataset.uiCopyReady === '1') return;
|
||||
button.dataset.uiCopyReady = '1';
|
||||
|
||||
button.addEventListener('click', async function () {
|
||||
const target = document.querySelector(button.dataset.copyTarget);
|
||||
if (!target) return;
|
||||
const text = target.value || target.textContent || '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.trim());
|
||||
const original = button.textContent;
|
||||
button.textContent = '✅ Skopiowano';
|
||||
setTimeout(function () {
|
||||
button.textContent = original;
|
||||
}, 1800);
|
||||
} catch (err) {
|
||||
console.warn('Copy failed', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireUnsavedWarnings() {
|
||||
const trackedForms = Array.from(document.querySelectorAll('form[data-unsaved-warning="true"]'));
|
||||
if (!trackedForms.length) return;
|
||||
|
||||
trackedForms.forEach(function (form) {
|
||||
if (form.dataset.uiUnsavedReady === '1') return;
|
||||
form.dataset.uiUnsavedReady = '1';
|
||||
form.dataset.uiDirty = '0';
|
||||
|
||||
const markDirty = function () {
|
||||
form.dataset.uiDirty = '1';
|
||||
form.classList.add('is-dirty');
|
||||
};
|
||||
|
||||
form.addEventListener('input', markDirty);
|
||||
form.addEventListener('change', markDirty);
|
||||
form.addEventListener('submit', function () {
|
||||
form.dataset.uiDirty = '0';
|
||||
form.classList.remove('is-dirty');
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
const hasDirty = trackedForms.some(function (form) {
|
||||
return form.dataset.uiDirty === '1';
|
||||
});
|
||||
if (!hasDirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function enhanceMobileTables() {
|
||||
document.querySelectorAll('table').forEach(function (table) {
|
||||
if (table.dataset.mobileLabelsReady === '1') return;
|
||||
const headers = Array.from(table.querySelectorAll('thead th')).map(function (th) {
|
||||
return (th.innerText || '').trim();
|
||||
});
|
||||
if (!headers.length) return;
|
||||
table.querySelectorAll('tbody tr').forEach(function (row) {
|
||||
Array.from(row.children).forEach(function (cell, index) {
|
||||
if (!cell.dataset.label && headers[index]) {
|
||||
cell.dataset.label = headers[index];
|
||||
}
|
||||
});
|
||||
});
|
||||
table.dataset.mobileLabelsReady = '1';
|
||||
});
|
||||
}
|
||||
|
||||
function wireAdminNavToggle() {
|
||||
const toggle = document.querySelector('[data-admin-nav-toggle]');
|
||||
const nav = document.querySelector('[data-admin-nav-body]');
|
||||
if (!toggle || !nav) return;
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
const expanded = toggle.getAttribute('aria-expanded') === 'true';
|
||||
toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
||||
nav.classList.toggle('is-open', !expanded);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function initResponsiveCategoryBadges() {
|
||||
const headings = Array.from(document.querySelectorAll('[data-mobile-list-heading]'));
|
||||
if (!headings.length) return;
|
||||
|
||||
const update = function () {
|
||||
const isMobile = window.matchMedia('(max-width: 575.98px)').matches;
|
||||
|
||||
headings.forEach(function (heading) {
|
||||
const title = heading.querySelector('[data-mobile-list-title]');
|
||||
const group = heading.querySelector('[data-mobile-category-group]');
|
||||
if (!title || !group) return;
|
||||
|
||||
group.classList.remove('is-compact');
|
||||
if (!isMobile || !group.children.length) return;
|
||||
|
||||
const headingWidth = Math.ceil(heading.getBoundingClientRect().width);
|
||||
if (!headingWidth) return;
|
||||
|
||||
const titleRect = title.getBoundingClientRect();
|
||||
const groupRect = group.getBoundingClientRect();
|
||||
const titleWidth = Math.ceil(titleRect.width);
|
||||
const groupWidth = Math.ceil(group.scrollWidth);
|
||||
const wrapped = groupRect.top - titleRect.top > 4;
|
||||
const needsCompact = wrapped || (titleWidth + groupWidth > headingWidth);
|
||||
group.classList.toggle('is-compact', needsCompact);
|
||||
});
|
||||
};
|
||||
|
||||
let resizeTimer = null;
|
||||
window.addEventListener('resize', function () {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(update, 60);
|
||||
});
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const observer = new ResizeObserver(update);
|
||||
headings.forEach(function (heading) {
|
||||
observer.observe(heading);
|
||||
});
|
||||
}
|
||||
|
||||
update();
|
||||
}
|
||||
43
shopping_app/static/js/categories_autosave.js
Normal file
@@ -0,0 +1,43 @@
|
||||
(function () {
|
||||
const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
|
||||
const $ = (sel, ctx = document) => ctx.querySelector(sel);
|
||||
|
||||
const saveCategories = async (listId, ids, names, listTitle) => {
|
||||
try {
|
||||
const res = await fetch(`/admin/edit_categories/${listId}/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ category_ids: ids })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || 'save_failed');
|
||||
|
||||
const cats = names.length ? names.join(', ') : 'brak';
|
||||
showToast(`Zapisano kategorie [${cats}] dla listy <b>${listTitle}</b>`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Autosave error:', err);
|
||||
showToast(`Błąd zapisu kategorii dla listy <b>${listTitle}</b>`, 'danger');
|
||||
}
|
||||
};
|
||||
|
||||
const timers = new Map();
|
||||
const debounce = (key, fn, delay = 300) => {
|
||||
clearTimeout(timers.get(key));
|
||||
timers.set(key, setTimeout(fn, delay));
|
||||
};
|
||||
|
||||
$$('.form-select[name^="categories_"]').forEach(select => {
|
||||
const listId = select.getAttribute('data-list-id') || select.name.replace('categories_', '');
|
||||
const listTitle = select.closest('tr')?.querySelector('td a')?.textContent.trim() || `#${listId}`;
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
const selectedOptions = Array.from(select.options).filter(o => o.selected);
|
||||
const ids = selectedOptions.map(o => o.value); // <-- ID
|
||||
const names = selectedOptions.map(o => o.textContent.trim());
|
||||
debounce(listId, () => saveCategories(listId, ids, names, listTitle));
|
||||
});
|
||||
});
|
||||
|
||||
const fallback = $('#fallback-save-btn');
|
||||
if (fallback) fallback.classList.add('d-none');
|
||||
})();
|
||||
11
shopping_app/static/js/categories_select_admin.js
Normal file
@@ -0,0 +1,11 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll("select.tom-dark").forEach(function (el) {
|
||||
new TomSelect(el, {
|
||||
plugins: ['remove_button'],
|
||||
persist: false,
|
||||
create: false,
|
||||
hidePlaceholder: true,
|
||||
dropdownParent: 'body'
|
||||
});
|
||||
});
|
||||
});
|
||||
18
shopping_app/static/js/category_modal.js
Normal file
@@ -0,0 +1,18 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('#categoriesModal .category-suggestion').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const select = document.getElementById('category_id');
|
||||
if (!select) return;
|
||||
|
||||
select.value = btn.dataset.catId || '';
|
||||
const form = btn.closest('form');
|
||||
if (form) {
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
179
shopping_app/static/js/chart_controls.js
Normal file
@@ -0,0 +1,179 @@
|
||||
// chart_controls.js
|
||||
// Logika UI: wybór zakresu, przełączanie dzienny/miesięczny, kategorie, show_all.
|
||||
// Współpracuje z window.loadExpenses (z expense_chart.js).
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toggleMonthlySplit = document.getElementById("toggleMonthlySplit");
|
||||
const toggleDailySplit = document.getElementById("toggleDailySplit");
|
||||
const toggleCategory = document.getElementById("toggleCategorySplit");
|
||||
const startDateInput = document.getElementById("startDate");
|
||||
const endDateInput = document.getElementById("endDate");
|
||||
const customRangeBtn = document.getElementById("customRangeBtn");
|
||||
const showAllCheckbox = document.getElementById("showAllLists");
|
||||
|
||||
// pomocnicze
|
||||
const iso = (d) => d.toISOString().split("T")[0];
|
||||
const today = () => new Date();
|
||||
const daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d; };
|
||||
|
||||
function setActiveTimeSplit(active) {
|
||||
const on = (btn) => { btn.classList.add("btn-primary"); btn.classList.remove("btn-outline-light"); btn.setAttribute("aria-pressed", "true"); };
|
||||
const off = (btn) => { btn.classList.remove("btn-primary"); btn.classList.add("btn-outline-light"); btn.setAttribute("aria-pressed", "false"); };
|
||||
if (active === "monthly") { on(toggleMonthlySplit); off(toggleDailySplit); }
|
||||
else { on(toggleDailySplit); off(toggleMonthlySplit); }
|
||||
}
|
||||
function isDailyActive() { return toggleDailySplit?.classList.contains("btn-primary"); }
|
||||
|
||||
// ——— KLUCZOWE: jedno miejsce, które przeładowuje wykres zgodnie z aktualnym trybem ———
|
||||
function reloadRespectingSplit(preferredRange = null) {
|
||||
// preferredRange używamy dla przycisków typu monthly/quarterly/halfyearly/yearly
|
||||
const sd = startDateInput?.value || null;
|
||||
const ed = endDateInput?.value || null;
|
||||
|
||||
if (isDailyActive()) {
|
||||
// Dzienny ZAWSZE z datami (fallback: ostatnie 30 dni), bo inaczej backend spadnie na monthly
|
||||
const _sd = sd && ed ? sd : iso(daysAgo(30));
|
||||
const _ed = sd && ed ? ed : iso(today());
|
||||
window.loadExpenses("daily", _sd, _ed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Miesięczny
|
||||
if (sd && ed) {
|
||||
window.loadExpenses("monthly", sd, ed);
|
||||
} else if (preferredRange) {
|
||||
window.loadExpenses(preferredRange);
|
||||
} else {
|
||||
window.loadExpenses("monthly");
|
||||
}
|
||||
}
|
||||
|
||||
// ——— Przełączniki czasu ———
|
||||
toggleMonthlySplit?.addEventListener("click", () => {
|
||||
setActiveTimeSplit("monthly");
|
||||
reloadRespectingSplit("monthly");
|
||||
});
|
||||
|
||||
toggleDailySplit?.addEventListener("click", () => {
|
||||
setActiveTimeSplit("daily");
|
||||
reloadRespectingSplit();
|
||||
});
|
||||
|
||||
// ——— Podział na kategorie ———
|
||||
toggleCategory?.addEventListener("click", function () {
|
||||
const active = this.classList.contains("btn-primary");
|
||||
if (active) {
|
||||
this.classList.remove("btn-primary");
|
||||
this.classList.add("btn-outline-light");
|
||||
this.setAttribute("aria-pressed", "false");
|
||||
this.textContent = "Przełącz na kategorie";
|
||||
window.setCategorySplit(false);
|
||||
} else {
|
||||
this.classList.add("btn-primary");
|
||||
this.classList.remove("btn-outline-light");
|
||||
this.setAttribute("aria-pressed", "true");
|
||||
this.textContent = "Przełącz na sumy";
|
||||
window.setCategorySplit(true);
|
||||
}
|
||||
|
||||
// porzucenie zakresu
|
||||
document.querySelectorAll("#chartTab .range-btn").forEach(b => b.classList.remove("active"));
|
||||
reloadRespectingSplit();
|
||||
});
|
||||
|
||||
|
||||
// ——— Własny zakres ———
|
||||
customRangeBtn?.addEventListener("click", function () {
|
||||
const sd = startDateInput?.value;
|
||||
const ed = endDateInput?.value;
|
||||
if (!(sd && ed)) return alert("Proszę wybrać obie daty!");
|
||||
reloadRespectingSplit();
|
||||
});
|
||||
|
||||
// ——— Predefiniowane zakresy pod wykresem ———
|
||||
document.querySelectorAll("#chartTab .range-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", function () {
|
||||
document.querySelectorAll("#chartTab .range-btn").forEach((b) => b.classList.remove("active"));
|
||||
this.classList.add("active");
|
||||
const r = this.getAttribute("data-range"); // last30days/currentmonth/monthly/quarterly/halfyearly/yearly
|
||||
|
||||
// Zakresy kubełkowane – bez start/end, bez "daily"
|
||||
if (["monthly", "quarterly", "halfyearly", "yearly"].includes(r)) {
|
||||
if (startDateInput) startDateInput.value = "";
|
||||
if (endDateInput) endDateInput.value = "";
|
||||
window.loadExpenses(r); // => /expenses_data?range=monthly|quarterly|halfyearly|yearly
|
||||
return;
|
||||
}
|
||||
|
||||
if (r === "currentmonth") {
|
||||
const t = today();
|
||||
const first = new Date(t.getFullYear(), t.getMonth(), 1);
|
||||
if (isDailyActive()) {
|
||||
window.loadExpenses("daily", iso(first), iso(t));
|
||||
} else {
|
||||
window.loadExpenses("monthly", iso(first), iso(t));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r === "last30days") {
|
||||
if (isDailyActive()) {
|
||||
window.loadExpenses("daily", iso(daysAgo(30)), iso(today()));
|
||||
} else {
|
||||
window.loadExpenses("last30days");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// reset pickera
|
||||
if (startDateInput) startDateInput.value = "";
|
||||
if (endDateInput) endDateInput.value = "";
|
||||
|
||||
reloadRespectingSplit(r);
|
||||
});
|
||||
});
|
||||
|
||||
// ——— KATEGORIE (🌐 Wszystkie + pojedyncze) ———
|
||||
document.querySelectorAll(".category-filter").forEach((btn) => {
|
||||
btn.addEventListener("click", function () {
|
||||
// UI: podmień podświetlenie
|
||||
document.querySelectorAll(".category-filter").forEach(b => {
|
||||
b.classList.remove("btn-success");
|
||||
b.classList.add("btn-outline-light");
|
||||
});
|
||||
this.classList.add("btn-success");
|
||||
this.classList.remove("btn-outline-light");
|
||||
|
||||
// Zapisz filtr kategorii do globalnej zmiennej, którą odczytuje expense_chart.js
|
||||
const cid = this.getAttribute("data-category-id") || "";
|
||||
window.selectedCategoryId = cid;
|
||||
|
||||
// I ważne: przeładuj zgodnie z aktualnym trybem (to naprawia Twój przypadek #1)
|
||||
reloadRespectingSplit();
|
||||
});
|
||||
});
|
||||
|
||||
// ——— SHOW ALL (Uwzględnij listy udostępnione/publiczne) ———
|
||||
showAllCheckbox?.addEventListener("change", () => {
|
||||
reloadRespectingSplit();
|
||||
});
|
||||
|
||||
|
||||
// ——— Inicjalizacja ———
|
||||
// Podpowiedź dat do inputów
|
||||
|
||||
|
||||
//if (startDateInput && endDateInput) {
|
||||
// startDateInput.value = iso(daysAgo(7));
|
||||
// endDateInput.value = iso(today());
|
||||
//}
|
||||
|
||||
if (startDateInput && endDateInput) {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
startDateInput.value = iso(startOfMonth);
|
||||
endDateInput.value = iso(now);
|
||||
}
|
||||
|
||||
setActiveTimeSplit("daily");
|
||||
reloadRespectingSplit();
|
||||
});
|
||||
39
shopping_app/static/js/clickable_row.js
Normal file
@@ -0,0 +1,39 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const itemsContainer = document.getElementById('items');
|
||||
if (!itemsContainer) return;
|
||||
|
||||
itemsContainer.addEventListener('click', function (e) {
|
||||
const row = e.target.closest('.clickable-item');
|
||||
if (!row || !itemsContainer.contains(row)) return;
|
||||
|
||||
if (e.target.closest('button') || e.target.tagName.toLowerCase() === 'input') {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkbox = row.querySelector('input[type="checkbox"]');
|
||||
if (!checkbox || checkbox.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemId = parseInt(row.id.replace('item-', ''), 10);
|
||||
if (isNaN(itemId)) return;
|
||||
|
||||
if (checkbox.checked) {
|
||||
socket.emit('uncheck_item', { item_id: itemId });
|
||||
} else {
|
||||
socket.emit('check_item', { item_id: itemId });
|
||||
}
|
||||
|
||||
checkbox.disabled = true;
|
||||
row.classList.add('opacity-50', 'is-pending');
|
||||
|
||||
let existingSpinner = row.querySelector('.shopping-item-spinner');
|
||||
if (!existingSpinner) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
row.appendChild(spinner);
|
||||
}
|
||||
});
|
||||
});
|
||||
20
shopping_app/static/js/confirm_delete.js
Normal file
@@ -0,0 +1,20 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const input = document.getElementById('confirm-delete-input');
|
||||
const button = document.getElementById('confirm-delete-btn');
|
||||
let timer = null;
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
button.disabled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
if (input.value.trim().toLowerCase() === 'usuń') {
|
||||
timer = setTimeout(() => {
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
button.addEventListener('click', function () {
|
||||
document.getElementById('delete-form').submit();
|
||||
});
|
||||
});
|
||||
67
shopping_app/static/js/download_chart.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// download_chart.js — eksport PNG z ciemnym tłem (tymczasowo), bez wielokrotnego bindowania
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dlBtn = document.getElementById("downloadMainChartBtn");
|
||||
if (!dlBtn) return;
|
||||
|
||||
// helper: bezpieczna nazwa pliku
|
||||
const sanitize = (s) =>
|
||||
(s || "")
|
||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9-_]+/g, "_")
|
||||
.replace(/_+/g, "_").replace(/^_+|_+$/g, "");
|
||||
|
||||
// helper: eksport z tymczasowym tłem
|
||||
const exportChartPNG = (chart, bgColor = "#1e1e1e") => {
|
||||
const canvas = chart.canvas;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// 1) zapisz obraz
|
||||
const snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 2) podłóż tło pod istniejący rysunek
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "destination-over";
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.restore();
|
||||
|
||||
// 3) wygeneruj PNG
|
||||
const dataUrl = chart.toBase64Image("image/png", 1.0);
|
||||
|
||||
// 4) przywróć pierwotny obraz (transparentny)
|
||||
ctx.putImageData(snapshot, 0, 0);
|
||||
|
||||
return dataUrl;
|
||||
};
|
||||
|
||||
// jednorazowe bindowanie click
|
||||
if (!dlBtn.dataset.bound) {
|
||||
dlBtn.addEventListener("click", () => {
|
||||
const chart = window.expensesChart || Chart.getChart(document.getElementById("expensesChart"));
|
||||
if (!chart) return;
|
||||
|
||||
// nazwa: zakres + timestamp
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
||||
const rangeLabel = document.getElementById("chartRangeLabel")?.textContent || "";
|
||||
const filename = `wydatki-${sanitize(rangeLabel)}-${stamp}.png`;
|
||||
|
||||
// (opcjonalnie) upewnij się, że layout jest świeży
|
||||
chart.resize();
|
||||
chart.update("none");
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = exportChartPNG(chart, "#1e1e1e"); // tu ustawiasz kolor tła eksportu
|
||||
a.download = filename;
|
||||
a.click();
|
||||
});
|
||||
dlBtn.dataset.bound = "1";
|
||||
}
|
||||
|
||||
// aktywuj przycisk, gdy wykres istnieje
|
||||
const enableIfReady = () => { dlBtn.disabled = !window.expensesChart; };
|
||||
document.addEventListener("expensesChart:ready", enableIfReady);
|
||||
enableIfReady();
|
||||
});
|
||||
150
shopping_app/static/js/expense_chart.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// expense_chart.js
|
||||
// Czyste generowanie wykresu + publiczne API: window.loadExpenses, window.setCategorySplit
|
||||
// Współpracuje z backendem /expenses_data (range_type, start/end, by_category) – patrz app.py :contentReference[oaicite:3]{index=3}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
let expensesChart = null;
|
||||
let categorySplit = false; // domyślnie wykres całościowy; przycisk w HTML startuje z aria-pressed="false"
|
||||
|
||||
const rangeLabel = document.getElementById("chartRangeLabel");
|
||||
const showAllCheckbox = document.getElementById("showAllLists");
|
||||
const ctx = document.getElementById("expensesChart")?.getContext("2d");
|
||||
|
||||
// Pomocnicze
|
||||
const iso = (d) => d.toISOString().split("T")[0];
|
||||
const today = () => new Date();
|
||||
const daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d; };
|
||||
|
||||
// Jeśli ktoś nie wstrzyknął globalnie selectedCategoryId (np. przez inny widok),
|
||||
// zapewniamy istnienie zmiennej:
|
||||
if (typeof window.selectedCategoryId === "undefined") {
|
||||
window.selectedCategoryId = "";
|
||||
}
|
||||
|
||||
// Ustawia tryb podziału na kategorie, bez odświeżania (kontroler zadzwoni potem w loadExpenses)
|
||||
function setCategorySplit(on) {
|
||||
categorySplit = !!on;
|
||||
}
|
||||
|
||||
// Budowa URL dla /expenses_data zgodnie z backendem (range/start/end/show_all/category_id/by_category) :contentReference[oaicite:4]{index=4}
|
||||
function buildUrl(range, startDate, endDate) {
|
||||
let url = `/expenses_data?range=${encodeURIComponent(range)}`;
|
||||
|
||||
// show_all
|
||||
if (showAllCheckbox) {
|
||||
url += showAllCheckbox.checked ? "&show_all=true" : "&show_all=false";
|
||||
} else {
|
||||
url += "&show_all=true";
|
||||
}
|
||||
|
||||
// daty (dodaj tylko, gdy kompletne)
|
||||
if (startDate && endDate) {
|
||||
url += `&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`;
|
||||
}
|
||||
|
||||
// filtr kategorii list (z listy, nie "podziału na kategorie" na wykresie)
|
||||
if (window.selectedCategoryId) {
|
||||
url += `&category_id=${encodeURIComponent(window.selectedCategoryId)}`;
|
||||
}
|
||||
|
||||
// podział na kategorie na wykresie
|
||||
if (categorySplit) {
|
||||
url += "&by_category=true";
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Label dla UI
|
||||
function applyRangeLabel(range, startDate, endDate) {
|
||||
if (startDate && endDate) {
|
||||
rangeLabel.textContent = `Widok: własny zakres (${startDate} → ${endDate})`;
|
||||
return;
|
||||
}
|
||||
const map = {
|
||||
last30days: "Widok: ostatnie 30 dni",
|
||||
currentmonth: "Widok: bieżący miesiąc",
|
||||
monthly: "Widok: miesięczne",
|
||||
quarterly: "Widok: kwartalne",
|
||||
halfyearly: "Widok: półroczne",
|
||||
yearly: "Widok: roczne",
|
||||
daily: "Widok: dzienne",
|
||||
};
|
||||
rangeLabel.textContent = map[range] || "Widok: miesięczne";
|
||||
}
|
||||
|
||||
// Publiczne API – kontroler zawsze woła nas z odpowiednim 'range' i (dla daily) z datami.
|
||||
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
|
||||
// Naprawa: daily bez dat => ostatnie 30 dni
|
||||
if (range === "daily" && !(startDate && endDate)) {
|
||||
startDate = iso(daysAgo(30));
|
||||
endDate = iso(today());
|
||||
}
|
||||
|
||||
const url = buildUrl(range, startDate, endDate);
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!ctx) return;
|
||||
|
||||
if (expensesChart) { expensesChart.destroy(); window.expensesChart = null; }
|
||||
|
||||
//if (expensesChart) expensesChart.destroy();
|
||||
|
||||
const tooltipOptions = {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
if (context.parsed.y === 0) return "";
|
||||
return (context.dataset.label || "Suma") + ": " + context.parsed.y;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (categorySplit) {
|
||||
// Stacked per-kategoria – backend zwraca datasets z labelami kategorii :contentReference[oaicite:6]{index=6}
|
||||
expensesChart = new Chart(ctx, {
|
||||
|
||||
type: "bar",
|
||||
data: { labels: data.labels || [], datasets: data.datasets || [] },
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { tooltip: tooltipOptions, legend: { position: "top" } },
|
||||
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true } },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Całościowo – backend zwraca labels + expenses (sumy) :contentReference[oaicite:7]{index=7}
|
||||
expensesChart = new Chart(ctx, {
|
||||
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: "Suma wydatków [PLN]",
|
||||
data: data.expenses || [],
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { tooltip: tooltipOptions },
|
||||
scales: { y: { beginAtZero: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// na potrzeby otwarciu w modalu
|
||||
window.expensesChart = expensesChart;
|
||||
document.dispatchEvent(new Event('expensesChart:ready'));
|
||||
|
||||
applyRangeLabel(range, startDate, endDate);
|
||||
})
|
||||
.catch((e) => console.error("Błąd pobierania danych:", e));
|
||||
}
|
||||
|
||||
// Eksport publiczny dla kontrolerów
|
||||
window.loadExpenses = loadExpenses;
|
||||
window.setCategorySplit = setCategorySplit;
|
||||
});
|
||||
11
shopping_app/static/js/expense_tab.js
Normal file
@@ -0,0 +1,11 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Sprawdzamy, czy hash w URL to #chartTab
|
||||
if (window.location.hash === "#chartTab") {
|
||||
const chartTabTrigger = document.querySelector('#chart-tab');
|
||||
if (chartTabTrigger) {
|
||||
// Wymuszenie aktywacji zakładki Bootstrap
|
||||
const tab = new bootstrap.Tab(chartTabTrigger);
|
||||
tab.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
173
shopping_app/static/js/expense_table.js
Normal file
@@ -0,0 +1,173 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const checkboxes = document.querySelectorAll('.list-checkbox');
|
||||
const totalEl = document.getElementById('listsTotal');
|
||||
const filterButtons = document.querySelectorAll('.range-btn');
|
||||
const rows = document.querySelectorAll('#listsTableBody tr');
|
||||
const categoryButtons = document.querySelectorAll('.category-filter');
|
||||
const applyCustomBtn = document.getElementById('applyCustomRange');
|
||||
const customStartInput = document.getElementById('customStart');
|
||||
const customEndInput = document.getElementById('customEnd');
|
||||
|
||||
if (customStartInput && customEndInput) {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
customStartInput.value = `${y}-${m}-01`;
|
||||
customEndInput.value = `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
window.selectedCategoryId = "";
|
||||
let initialLoad = true;
|
||||
|
||||
function updateTotal() {
|
||||
let total = 0;
|
||||
checkboxes.forEach(cb => {
|
||||
const row = cb.closest('tr');
|
||||
if (cb.checked && row.style.display !== 'none') {
|
||||
total += parseFloat(cb.dataset.amount);
|
||||
}
|
||||
});
|
||||
totalEl.textContent = total.toFixed(2) + ' PLN';
|
||||
}
|
||||
|
||||
function getISOWeek(date) {
|
||||
const target = new Date(date.valueOf());
|
||||
const dayNr = (date.getDay() + 6) % 7;
|
||||
target.setDate(target.getDate() - dayNr + 3);
|
||||
const firstThursday = new Date(target.getFullYear(), 0, 4);
|
||||
const dayDiff = (target - firstThursday) / 86400000;
|
||||
return 1 + Math.floor(dayDiff / 7);
|
||||
}
|
||||
|
||||
function filterByRange(range) {
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
const year = now.getFullYear();
|
||||
const month = now.toISOString().slice(0, 7);
|
||||
const week = `${year}-${String(getISOWeek(now)).padStart(2, '0')}`;
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
if (range === 'last30days') {
|
||||
endDate = now;
|
||||
startDate = new Date();
|
||||
startDate.setDate(endDate.getDate() - 29);
|
||||
}
|
||||
if (range === 'currentmonth') {
|
||||
startDate = new Date(year, now.getMonth(), 1);
|
||||
endDate = now;
|
||||
}
|
||||
rows.forEach(row => {
|
||||
const rDate = row.dataset.date;
|
||||
const rMonth = row.dataset.month;
|
||||
const rWeek = row.dataset.week;
|
||||
const rYear = row.dataset.year;
|
||||
const rowDateObj = new Date(rDate);
|
||||
let show = true;
|
||||
if (range === 'day') show = rDate === todayStr;
|
||||
else if (range === 'month') show = rMonth === month;
|
||||
else if (range === 'week') show = rWeek === week;
|
||||
else if (range === 'year') show = rYear === String(year);
|
||||
else if (range === 'all') show = true;
|
||||
else if (range === 'last30days') show = rowDateObj >= startDate && rowDateObj <= endDate;
|
||||
else if (range === 'currentmonth') show = rowDateObj >= startDate && rowDateObj <= endDate;
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function filterByLast30Days() {
|
||||
filterByRange('last30days');
|
||||
}
|
||||
|
||||
function applyExpenseFilter() {
|
||||
rows.forEach(row => {
|
||||
const amt = parseFloat(row.querySelector('.list-checkbox').dataset.amount || 0);
|
||||
if (amt <= 0) row.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function applyCategoryFilter() {
|
||||
if (!window.selectedCategoryId) return;
|
||||
rows.forEach(row => {
|
||||
const categoriesStr = row.dataset.categories || "";
|
||||
const categories = categoriesStr ? categoriesStr.split(",") : [];
|
||||
if (window.selectedCategoryId === "none") {
|
||||
if (categoriesStr.trim() !== "") row.style.display = 'none';
|
||||
} else {
|
||||
if (!categories.includes(String(window.selectedCategoryId))) row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function filterByCustomRange(startStr, endStr) {
|
||||
const start = new Date(startStr);
|
||||
const end = new Date(endStr);
|
||||
if (isNaN(start) || isNaN(end)) return;
|
||||
end.setHours(23, 59, 59, 999);
|
||||
rows.forEach(row => {
|
||||
const rowDateObj = new Date(row.dataset.date);
|
||||
const show = rowDateObj >= start && rowDateObj <= end;
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
checkboxes.forEach(cb => cb.addEventListener('change', updateTotal));
|
||||
|
||||
filterButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
initialLoad = false;
|
||||
filterButtons.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const range = btn.dataset.range;
|
||||
filterByRange(range);
|
||||
applyExpenseFilter();
|
||||
applyCategoryFilter();
|
||||
updateTotal();
|
||||
});
|
||||
});
|
||||
|
||||
categoryButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
categoryButtons.forEach(b => b.classList.remove('btn-success', 'active'));
|
||||
categoryButtons.forEach(b => b.classList.add('btn-outline-light'));
|
||||
btn.classList.remove('btn-outline-light');
|
||||
btn.classList.add('btn-success', 'active');
|
||||
window.selectedCategoryId = btn.dataset.categoryId || "";
|
||||
if (initialLoad) {
|
||||
filterByLast30Days();
|
||||
} else {
|
||||
const activeRange = document.querySelector('.range-btn.active');
|
||||
if (activeRange) filterByRange(activeRange.dataset.range);
|
||||
}
|
||||
applyExpenseFilter();
|
||||
applyCategoryFilter();
|
||||
updateTotal();
|
||||
const chartTab = document.querySelector('#chart-tab');
|
||||
if (chartTab && chartTab.classList.contains('active') && typeof window.loadExpenses === 'function') {
|
||||
window.loadExpenses();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (applyCustomBtn) {
|
||||
applyCustomBtn.addEventListener('click', () => {
|
||||
const startStr = customStartInput?.value;
|
||||
const endStr = customEndInput?.value;
|
||||
if (!startStr || !endStr) {
|
||||
alert('Proszę wybrać obie daty!');
|
||||
return;
|
||||
}
|
||||
initialLoad = false;
|
||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||||
filterByCustomRange(startStr, endStr);
|
||||
applyExpenseFilter();
|
||||
applyCategoryFilter();
|
||||
updateTotal();
|
||||
});
|
||||
}
|
||||
|
||||
filterByLast30Days();
|
||||
applyExpenseFilter();
|
||||
applyCategoryFilter();
|
||||
updateTotal();
|
||||
});
|
||||
469
shopping_app/static/js/functions.js
Normal file
@@ -0,0 +1,469 @@
|
||||
function updateItemState(itemId, isChecked) {
|
||||
const checkbox = document.querySelector(`#item-${itemId} input[type='checkbox']`);
|
||||
if (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
checkbox.disabled = false;
|
||||
const li = checkbox.closest('li');
|
||||
li.classList.remove('opacity-50', 'is-pending', 'bg-light', 'text-dark', 'bg-success', 'text-white', 'bg-warning', 'item-not-checked');
|
||||
|
||||
if (isChecked) {
|
||||
li.classList.add('bg-success', 'text-white');
|
||||
} else {
|
||||
li.classList.add('item-not-checked');
|
||||
}
|
||||
|
||||
li.querySelectorAll('.shopping-item-spinner, .spinner-border').forEach(sp => sp.remove());
|
||||
}
|
||||
updateProgressBar();
|
||||
applyHidePurchased();
|
||||
}
|
||||
|
||||
function updateProgressBar() {
|
||||
const barPurchased = document.getElementById('progress-bar-purchased');
|
||||
const barNotPurchased = document.getElementById('progress-bar-not-purchased');
|
||||
const barRemaining = document.getElementById('progress-bar-remaining');
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const percentValueEl = document.getElementById('percent-value');
|
||||
|
||||
if (!barPurchased || !barNotPurchased || !barRemaining || !progressLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = document.querySelectorAll('#items li');
|
||||
const total = items.length;
|
||||
|
||||
const purchased = Array.from(items).filter(li => li.classList.contains('bg-success')).length;
|
||||
const notPurchased = Array.from(items).filter(li => li.classList.contains('bg-warning')).length;
|
||||
const remaining = total - purchased - notPurchased;
|
||||
|
||||
const percentPurchased = total > 0 ? (purchased / total) * 100 : 0;
|
||||
const percentNotPurchased = total > 0 ? (notPurchased / total) * 100 : 0;
|
||||
const percentRemaining = total > 0 ? (remaining / total) * 100 : 0;
|
||||
|
||||
const percent = total > 0 ? Math.round((purchased / total) * 100) : 0;
|
||||
|
||||
barPurchased.style.width = `${percentPurchased}%`;
|
||||
barNotPurchased.style.width = `${percentNotPurchased}%`;
|
||||
barRemaining.style.width = `${percentRemaining}%`;
|
||||
|
||||
progressLabel.textContent = `${percent}%`;
|
||||
progressLabel.classList.toggle('text-white', percent < 51);
|
||||
progressLabel.classList.toggle('text-dark', percent >= 51);
|
||||
|
||||
const purchasedCountEl = document.getElementById('purchased-count');
|
||||
const totalCountEl = document.getElementById('total-count');
|
||||
|
||||
if (purchasedCountEl) purchasedCountEl.textContent = purchased;
|
||||
if (totalCountEl) totalCountEl.textContent = total;
|
||||
if (percentValueEl) percentValueEl.textContent = percent;
|
||||
}
|
||||
|
||||
function addItem(listId) {
|
||||
const name = document.getElementById('newItem').value;
|
||||
const quantityInput = document.getElementById('newQuantity');
|
||||
let quantity = 1;
|
||||
|
||||
if (quantityInput) {
|
||||
quantity = parseInt(quantityInput.value);
|
||||
if (isNaN(quantity) || quantity < 1) {
|
||||
quantity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (name.trim() === '') return;
|
||||
|
||||
socket.emit('add_item', { list_id: listId, name: name, quantity: quantity });
|
||||
|
||||
document.getElementById('newItem').value = '';
|
||||
if (quantityInput) quantityInput.value = 1;
|
||||
document.getElementById('newItem').focus();
|
||||
}
|
||||
|
||||
function deleteItem(id) {
|
||||
if (confirm('Na pewno usunąć produkt?')) {
|
||||
socket.emit('delete_item', { item_id: id });
|
||||
}
|
||||
}
|
||||
|
||||
function editItem(id, oldName, oldQuantity) {
|
||||
const finalName = String(oldName ?? '').trim();
|
||||
let newQuantity = parseInt(oldQuantity, 10);
|
||||
|
||||
if (!finalName) {
|
||||
showToast('Nazwa produktu nie może być pusta.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(newQuantity) || newQuantity < 1) {
|
||||
newQuantity = 1;
|
||||
}
|
||||
|
||||
socket.emit('edit_item', { item_id: id, new_name: finalName, new_quantity: newQuantity });
|
||||
}
|
||||
|
||||
function openEditItemModal(event, id, oldName, oldQuantity) {
|
||||
if (event && typeof event.stopPropagation === 'function') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const modalEl = document.getElementById('editItemModal');
|
||||
const idInput = document.getElementById('editItemId');
|
||||
const nameInput = document.getElementById('editItemName');
|
||||
const quantityInput = document.getElementById('editItemQuantity');
|
||||
|
||||
if (!modalEl || !idInput || !nameInput || !quantityInput || typeof bootstrap === 'undefined') {
|
||||
editItem(id, oldName, oldQuantity);
|
||||
return;
|
||||
}
|
||||
|
||||
idInput.value = id;
|
||||
nameInput.value = String(oldName ?? '').trim();
|
||||
|
||||
const parsedQuantity = parseInt(oldQuantity, 10);
|
||||
quantityInput.value = !isNaN(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : 1;
|
||||
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
modal.show();
|
||||
|
||||
setTimeout(() => {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function submitExpense(listId) {
|
||||
const amountInput = document.getElementById('expenseAmount');
|
||||
const amount = parseFloat(amountInput.value);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
showToast('Podaj poprawną kwotę!');
|
||||
return;
|
||||
}
|
||||
socket.emit('add_expense', {
|
||||
list_id: listId,
|
||||
amount: amount
|
||||
});
|
||||
amountInput.value = '';
|
||||
}
|
||||
|
||||
function copyLink(link) {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'Udostępnij link',
|
||||
text: 'Udostępniam link do listy:',
|
||||
url: link
|
||||
}).then(() => {
|
||||
showToast('Link udostępniony!');
|
||||
}).catch((err) => {
|
||||
tryClipboard(link);
|
||||
});
|
||||
return;
|
||||
}
|
||||
tryClipboard(link);
|
||||
}
|
||||
|
||||
function tryClipboard(link) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
showToast('Link skopiowany do schowka!');
|
||||
}).catch((err) => {
|
||||
console.error('Błąd clipboard API:', err);
|
||||
fallbackCopyText(link);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyText(link);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopyText(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = 0;
|
||||
textarea.style.left = 0;
|
||||
textarea.style.opacity = 0;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
showToast('Link skopiowany do schowka!');
|
||||
} else {
|
||||
showToast('Nie udało się skopiować linku', 'warning');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback błąd kopiowania:', err);
|
||||
showToast('Nie udało się skopiować linku', 'warning');
|
||||
}
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function openList(link) {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
|
||||
function applyHidePurchased(isInit = false) {
|
||||
const toggle = document.getElementById('hidePurchasedToggle');
|
||||
if (!toggle) return;
|
||||
const hide = toggle.checked;
|
||||
|
||||
const items = document.querySelectorAll('#items li');
|
||||
|
||||
items.forEach(li => {
|
||||
const isCheckedItem =
|
||||
li.classList.contains('bg-success') || // kupione
|
||||
li.classList.contains('bg-warning'); // niekupione
|
||||
|
||||
if (isCheckedItem) {
|
||||
if (hide) {
|
||||
if (isInit) {
|
||||
// Jeśli inicjalizacja: od razu ukryj
|
||||
li.classList.add('hide-purchased');
|
||||
li.classList.remove('fade-out');
|
||||
} else {
|
||||
// Z animacją
|
||||
li.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
li.classList.add('hide-purchased');
|
||||
}, 700);
|
||||
}
|
||||
} else {
|
||||
// Odsłanianie
|
||||
li.classList.remove('hide-purchased');
|
||||
setTimeout(() => {
|
||||
li.classList.remove('fade-out');
|
||||
}, 10);
|
||||
}
|
||||
} else {
|
||||
// Element nieoznaczony — zawsze pokazany
|
||||
li.classList.remove('hide-purchased', 'fade-out');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVisibility(listId) {
|
||||
fetch('/toggle_visibility/' + listId, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const shareHeader = document.getElementById('share-header');
|
||||
const shareUrlSpan = document.getElementById('share-url');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const toggleBtn = document.getElementById('toggleVisibilityBtn');
|
||||
|
||||
// URL zawsze widoczny i aktywny
|
||||
shareUrlSpan.style.display = 'inline';
|
||||
shareUrlSpan.textContent = data.share_url;
|
||||
copyBtn.disabled = false;
|
||||
|
||||
if (data.is_public) {
|
||||
shareHeader.textContent = '🔗 Udostępnij link (lista publiczna)';
|
||||
toggleBtn.innerHTML = '🙈 Ukryj listę';
|
||||
} else {
|
||||
shareHeader.textContent = '🔗 Udostępnij link (widoczna tylko przez link / uprawnienia)';
|
||||
toggleBtn.innerHTML = '🐵 Uczyń publiczną';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function markNotPurchasedModal(e, id) {
|
||||
e.stopPropagation();
|
||||
const reason = prompt("Podaj powód oznaczenia jako niekupione:");
|
||||
if (reason !== null) {
|
||||
socket.emit('mark_not_purchased', { item_id: id, reason: reason });
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'primary') {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-bg-${type} border-0 show`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body">${message}</div></div>`;
|
||||
toastContainer.appendChild(toast);
|
||||
setTimeout(() => { toast.remove(); }, 1750);
|
||||
}
|
||||
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
|
||||
function isListDifferent(oldItems, newItems) {
|
||||
if (oldItems.length !== newItems.length) return true;
|
||||
|
||||
const oldIds = Array.from(oldItems).map(li => parseInt(li.id.replace('item-', ''), 10)).sort();
|
||||
const newIds = newItems.map(i => i.id).sort();
|
||||
|
||||
for (let i = 0; i < newIds.length; i++) {
|
||||
if (oldIds[i] !== newIds[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = false) {
|
||||
const options = (typeof optionsOrShowEditOnly === 'object' && optionsOrShowEditOnly !== null)
|
||||
? optionsOrShowEditOnly
|
||||
: { showEditOnly: !!optionsOrShowEditOnly };
|
||||
|
||||
const showEditOnly = !!options.showEditOnly;
|
||||
const temporaryShareUndo = !!options.temporaryShareUndo;
|
||||
const countdownSeconds = Math.max(0, parseInt(options.countdownSeconds, 10) || 15);
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.id = `item-${item.id}`;
|
||||
li.dataset.name = String(item.name || '').toLowerCase();
|
||||
li.dataset.isShare = isShare ? 'true' : 'false';
|
||||
li.className = `list-group-item shopping-item-row clickable-item ${item.purchased ? 'bg-success text-white'
|
||||
: item.not_purchased ? 'bg-warning text-dark'
|
||||
: 'item-not-checked'
|
||||
}`;
|
||||
|
||||
const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true';
|
||||
const isArchived = window.IS_ARCHIVED === true || window.IS_ARCHIVED === 'true';
|
||||
const safeName = escapeHtml(item.name || '');
|
||||
const nameForEdit = JSON.stringify(String(item.name || ''));
|
||||
const quantity = Number.isInteger(item.quantity) ? item.quantity : parseInt(item.quantity, 10) || 1;
|
||||
const quantityBadge = quantity > 1
|
||||
? `<span class="badge rounded-pill bg-secondary">x${quantity}</span>`
|
||||
: '';
|
||||
|
||||
const canEditListItem = !isShare;
|
||||
const canShowShareActions = isShare && !showEditOnly && !temporaryShareUndo;
|
||||
const canMarkNotPurchased = !item.not_purchased && !isArchived;
|
||||
const checkboxHtml = `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''} ${(item.not_purchased || isArchived) ? 'disabled' : ''}>`;
|
||||
|
||||
const infoParts = [];
|
||||
if (item.note) {
|
||||
infoParts.push(`<span class="text-danger">[ <b>${escapeHtml(item.note)}</b> ]</span>`);
|
||||
}
|
||||
if (item.not_purchased_reason) {
|
||||
infoParts.push(`<span class="text-dark">[ <b>Powód: ${escapeHtml(item.not_purchased_reason)}</b> ]</span>`);
|
||||
}
|
||||
const addedByDisplay = item.added_by_display;
|
||||
if (addedByDisplay) {
|
||||
infoParts.push(`<span class="text-info">[ Dodał/a: <b>${escapeHtml(addedByDisplay)}</b> ]</span>`);
|
||||
}
|
||||
const infoHtml = infoParts.length
|
||||
? `<span class="info-line small" id="info-${item.id}">${infoParts.join(' ')}</span>`
|
||||
: '';
|
||||
|
||||
const iconBtn = 'btn btn-outline-light btn-sm shopping-action-btn';
|
||||
const wideBtn = 'btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide';
|
||||
let actionButtons = '';
|
||||
|
||||
if (canEditListItem) {
|
||||
const dragHandleButton = window.isSorting
|
||||
? `<button type="button" class="${iconBtn} drag-handle" title="Przesuń produkt" aria-label="Przesuń produkt" ${isArchived ? 'disabled' : ''}>☰</button>`
|
||||
: '';
|
||||
|
||||
actionButtons += `
|
||||
${dragHandleButton}
|
||||
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${JSON.stringify(String(item.name || ''))}, ${quantity})'`}>✏️</button>
|
||||
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑️</button>`;
|
||||
}
|
||||
|
||||
if (item.not_purchased) {
|
||||
actionButtons += `
|
||||
<button type="button" class="${wideBtn}" ${isArchived ? 'disabled' : `onclick="unmarkNotPurchased(${item.id})"`}>✅ Przywróć</button>`;
|
||||
} else if (!isShare || canShowShareActions || isOwner) {
|
||||
actionButtons += `
|
||||
<button type="button" class="${iconBtn}" ${canMarkNotPurchased ? `onclick="markNotPurchasedModal(event, ${item.id})"` : 'disabled'}>⚠️</button>`;
|
||||
}
|
||||
|
||||
if (temporaryShareUndo) {
|
||||
actionButtons += `
|
||||
<button type="button" class="${iconBtn} shopping-action-btn--countdown" disabled data-countdown-for="${item.id}">${countdownSeconds}s</button>
|
||||
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${nameForEdit}, ${quantity})'`}>✏️</button>
|
||||
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑️</button>`;
|
||||
} else if (canShowShareActions) {
|
||||
actionButtons += `
|
||||
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="openNoteModal(event, ${item.id})"`}>📝</button>`;
|
||||
}
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="shopping-item-main">
|
||||
${checkboxHtml}
|
||||
<div class="shopping-item-content">
|
||||
<div class="shopping-item-head">
|
||||
<div class="shopping-item-text">
|
||||
<span id="name-${item.id}" class="shopping-item-name text-white">${safeName}</span>
|
||||
${quantityBadge}
|
||||
${infoHtml}
|
||||
</div>
|
||||
<div class="list-item-actions shopping-item-actions" role="group">
|
||||
${actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function updateListSmoothly(newItems) {
|
||||
const itemsContainer = document.getElementById('items');
|
||||
const existingItemsMap = new Map();
|
||||
|
||||
Array.from(itemsContainer.querySelectorAll('li')).forEach(li => {
|
||||
const id = parseInt(li.id.replace('item-', ''), 10);
|
||||
existingItemsMap.set(id, li);
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
newItems.forEach(item => {
|
||||
const li = renderItem(item);
|
||||
fragment.appendChild(li);
|
||||
});
|
||||
|
||||
itemsContainer.innerHTML = '';
|
||||
itemsContainer.appendChild(fragment);
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
applyHidePurchased();
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const receiptSection = document.getElementById("receiptSection");
|
||||
const toggleBtn = document.querySelector('[data-bs-target="#receiptSection"]');
|
||||
|
||||
if (!receiptSection || !toggleBtn) return;
|
||||
|
||||
if (localStorage.getItem("receiptSectionOpen") === "true") {
|
||||
new bootstrap.Collapse(receiptSection, { toggle: true });
|
||||
}
|
||||
|
||||
receiptSection.addEventListener('shown.bs.collapse', function () {
|
||||
localStorage.setItem("receiptSectionOpen", "true");
|
||||
});
|
||||
|
||||
receiptSection.addEventListener('hidden.bs.collapse', function () {
|
||||
localStorage.setItem("receiptSectionOpen", "false");
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toggle = document.getElementById('hidePurchasedToggle');
|
||||
if (!toggle) return;
|
||||
|
||||
const savedState = localStorage.getItem('hidePurchasedToggle');
|
||||
toggle.checked = savedState === 'true';
|
||||
applyHidePurchased(true);
|
||||
toggle.addEventListener('change', function () {
|
||||
localStorage.setItem('hidePurchasedToggle', toggle.checked ? 'true' : 'false');
|
||||
applyHidePurchased();
|
||||
});
|
||||
});
|
||||
22
shopping_app/static/js/lists_access.js
Normal file
@@ -0,0 +1,22 @@
|
||||
(function () {
|
||||
const $=(s,r=document)=>r.querySelector(s); const $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
|
||||
const filterInput=$('#listFilter'),filterCount=$('#filterCount'),selectAll=$('#selectAll'),bulkTokens=$('#bulkTokens'),bulkInput=$('#bulkUsersInput'),bulkBtn=$('#bulkAddBtn');
|
||||
const unique=arr=>Array.from(new Set(arr));
|
||||
const parseUserText=txt=>unique((txt||'').split(/[\s,;]+/g).map(s=>s.trim().replace(/^@/,'').toLowerCase()).filter(Boolean));
|
||||
const selectedListIds=()=>$$('.row-check:checked').map(ch=>ch.dataset.listId);
|
||||
const visibleRows=()=>$$('#listsTable tbody tr').filter(r=>r.style.display!=='none');
|
||||
function applyFilter(){const q=(filterInput?.value||'').trim().toLowerCase();let shown=0;$$('#listsTable tbody tr').forEach(tr=>{const hay=`${tr.dataset.id||''} ${tr.dataset.title||''} ${tr.dataset.owner||''}`;const ok=!q||hay.includes(q);tr.style.display=ok?'':'none';if(ok) shown++;});if(filterCount) filterCount.textContent=shown?`Widoczne: ${shown}`:'Brak wyników';}
|
||||
filterInput?.addEventListener('input',applyFilter);applyFilter();
|
||||
selectAll?.addEventListener('change',()=>{visibleRows().forEach(tr=>{const cb=tr.querySelector('.row-check'); if(cb) cb.checked=selectAll.checked;});});
|
||||
$$('.copy-share').forEach(btn=>btn.addEventListener('click',async()=>{const url=btn.dataset.url;try{await navigator.clipboard.writeText(url);}catch{const ta=Object.assign(document.createElement('textarea'),{value:url});document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();}showToast('Skopiowano link udostępnienia','success');}));
|
||||
function addGlobalToken(username){if(!username) return;const exists=$(`.user-token[data-user="${username}"]`,bulkTokens);if(exists) return;const token=document.createElement('span');token.className='badge rounded-pill text-bg-secondary user-token';token.dataset.user=username;token.innerHTML=`@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;token.querySelector('button').addEventListener('click',()=>token.remove());bulkTokens.appendChild(token);}
|
||||
bulkInput?.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';}});
|
||||
bulkInput?.addEventListener('change',()=>{parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';});
|
||||
let hintCtrl=null;
|
||||
function renderBulkHints(users){const dl=$('#userHints'); if(!dl) return; dl.innerHTML=(users||[]).slice(0,20).map(u=>`<option value="${u}"></option>`).join('');}
|
||||
async function fetchBulkHints(q=''){const normalized=String(q||'').trim().replace(/^@/,'');try{hintCtrl?.abort();hintCtrl=new AbortController();const res=await fetch(`/admin/user-suggestions?q=${encodeURIComponent(normalized)}`,{credentials:'same-origin',signal:hintCtrl.signal});if(!res.ok) return renderBulkHints([]);const data=await res.json().catch(()=>({users:[]}));renderBulkHints(data.users||[]);}catch(e){renderBulkHints([]);}}
|
||||
bulkInput?.addEventListener('focus',()=>fetchBulkHints(bulkInput.value));
|
||||
bulkInput?.addEventListener('input',()=>fetchBulkHints(bulkInput.value));
|
||||
async function bulkGrant(){const lists=selectedListIds(), users=$$('.user-token',bulkTokens).map(t=>t.dataset.user);if(!lists.length) return showToast('Zaznacz przynajmniej jedną listę','warning');if(!users.length) return showToast('Dodaj przynajmniej jednego użytkownika','warning');bulkBtn.disabled=true;bulkBtn.textContent='Pracuję…';const url=location.pathname+location.search;let ok=0,fail=0;for(const lid of lists){for(const u of users){const form=new FormData();form.set('action','grant');form.set('target_list_id',lid);form.set('grant_username',u);try{const res=await fetch(url,{method:'POST',body:form,credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'fetch'}});if(res.ok) ok++; else fail++;}catch{fail++;}}}bulkBtn.disabled=false;bulkBtn.textContent='➕ Nadaj dostęp';showToast(`Gotowe. Sukcesy: ${ok}${fail?`, błędy: ${fail}`:''}`,fail?'danger':'success');if(ok) location.reload();}
|
||||
bulkBtn?.addEventListener('click',bulkGrant);
|
||||
})();
|
||||
@@ -7,13 +7,13 @@ function toggleEmptyPlaceholder() {
|
||||
|
||||
// prawdziwe <li> to te z data‑name lub id="item‑…"
|
||||
const hasRealItems = list.querySelector('li[data-name], li[id^="item-"]') !== null;
|
||||
const placeholder = document.getElementById('empty-placeholder');
|
||||
const placeholder = document.getElementById('empty-placeholder');
|
||||
|
||||
if (!hasRealItems && !placeholder) {
|
||||
const li = document.createElement('li');
|
||||
li.id = 'empty-placeholder';
|
||||
const li = document.createElement('li');
|
||||
li.id = 'empty-placeholder';
|
||||
li.className = 'list-group-item bg-dark text-secondary text-center w-100';
|
||||
li.textContent = 'Brak produktów w tej liście.';
|
||||
li.textContent = 'Brak produktów w tej liście.';
|
||||
list.appendChild(li);
|
||||
} else if (hasRealItems && placeholder) {
|
||||
placeholder.remove();
|
||||
@@ -88,15 +88,15 @@ function setupList(listId, username) {
|
||||
}
|
||||
|
||||
e.target.disabled = true;
|
||||
li.classList.add('opacity-50');
|
||||
li.classList.add('opacity-50', 'is-pending');
|
||||
|
||||
let existingSpinner = li.querySelector('.spinner-border');
|
||||
let existingSpinner = li.querySelector('.shopping-item-spinner');
|
||||
if (!existingSpinner) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner-border spinner-border-sm ms-2';
|
||||
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
|
||||
spinner.setAttribute('role', 'status');
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
e.target.parentElement.appendChild(spinner);
|
||||
li.appendChild(spinner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,38 +124,66 @@ function setupList(listId, username) {
|
||||
summaryEl.innerHTML = `<b>💸 Łącznie wydano:</b> ${data.total.toFixed(2)} PLN`;
|
||||
}
|
||||
|
||||
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`);
|
||||
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info');
|
||||
});
|
||||
|
||||
|
||||
socket.on('item_added', data => {
|
||||
showToast(`${data.added_by} dodał: ${data.name}`);
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap item-not-checked';
|
||||
li.id = `item-${data.id}`;
|
||||
|
||||
let quantityBadge = '';
|
||||
if (data.quantity && data.quantity > 1) {
|
||||
quantityBadge = `<span class="badge bg-secondary">x${data.quantity}</span>`;
|
||||
}
|
||||
showToast(`${data.added_by} dodał: ${data.name}`, 'info');
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<input class="large-checkbox" type="checkbox">
|
||||
<span id="name-${data.id}" class="text-white">${data.name} ${quantityBadge}</span>
|
||||
</div>
|
||||
<div class="mt-2 mt-md-0">
|
||||
<button class="btn btn-sm btn-outline-warning me-1" onclick="editItem(${data.id}, '${data.name}', ${data.quantity || 1})">✏️</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteItem(${data.id})">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
const item = {
|
||||
...data,
|
||||
purchased: false,
|
||||
not_purchased: false,
|
||||
not_purchased_reason: '',
|
||||
note: ''
|
||||
};
|
||||
|
||||
// #### WERSJA Z NAPISAMI ####
|
||||
// <button class="btn btn-sm btn-outline-warning me-1" onclick="editItem(${data.id}, '${data.name}', ${data.quantity || 1})">✏️ Edytuj</button>
|
||||
// <button class="btn btn-sm btn-outline-danger" onclick="deleteItem(${data.id})">🗑️ Usuń</button>
|
||||
const isOwnFreshShareItem = Boolean(
|
||||
window.IS_SHARE &&
|
||||
data.added_by &&
|
||||
window.CURRENT_LIST_USERNAME &&
|
||||
String(data.added_by) === String(window.CURRENT_LIST_USERNAME)
|
||||
);
|
||||
|
||||
const li = renderItem(
|
||||
item,
|
||||
window.IS_SHARE,
|
||||
isOwnFreshShareItem ? { temporaryShareUndo: true, countdownSeconds: 15 } : false
|
||||
);
|
||||
|
||||
document.getElementById('items').appendChild(li);
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
updateProgressBar();
|
||||
|
||||
if (isOwnFreshShareItem) {
|
||||
let seconds = 15;
|
||||
const intervalId = setInterval(() => {
|
||||
const currentItem = document.getElementById(`item-${data.id}`);
|
||||
const countdownEl = currentItem?.querySelector(`[data-countdown-for="${data.id}"]`);
|
||||
|
||||
if (!currentItem || !countdownEl) {
|
||||
clearInterval(intervalId);
|
||||
return;
|
||||
}
|
||||
|
||||
seconds -= 1;
|
||||
if (seconds <= 0) {
|
||||
clearInterval(intervalId);
|
||||
return;
|
||||
}
|
||||
|
||||
countdownEl.textContent = `${seconds}s`;
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(intervalId);
|
||||
const existing = document.getElementById(`item-${data.id}`);
|
||||
if (existing) {
|
||||
existing.replaceWith(renderItem(item, window.IS_SHARE));
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('item_deleted', data => {
|
||||
@@ -163,12 +191,12 @@ function setupList(listId, username) {
|
||||
if (li) {
|
||||
li.remove();
|
||||
}
|
||||
showToast('Usunięto produkt');
|
||||
showToast('Usunięto produkt z listy', 'success');
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
});
|
||||
|
||||
socket.on('progress_updated', function(data) {
|
||||
socket.on('progress_updated', function (data) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = data.percent + '%';
|
||||
@@ -178,51 +206,41 @@ function setupList(listId, username) {
|
||||
|
||||
const progressTitle = document.getElementById('progress-title');
|
||||
if (progressTitle) {
|
||||
progressTitle.textContent = `📊 Postęp listy — ${data.purchased_count}/${data.total_count} kupionych (${Math.round(data.percent)}%)`;
|
||||
progressTitle.textContent = `Postęp listy — ${data.purchased_count}/${data.total_count} kupionych (${Math.round(data.percent)}%)`;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('note_updated', data => {
|
||||
const itemEl = document.getElementById(`item-${data.item_id}`);
|
||||
if (itemEl) {
|
||||
let noteEl = itemEl.querySelector('small');
|
||||
if (noteEl) {
|
||||
//noteEl.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
|
||||
noteEl.innerHTML = `[ <b>${data.note}</b> ]`;
|
||||
} else {
|
||||
const newNote = document.createElement('small');
|
||||
newNote.className = 'text-danger ms-4';
|
||||
//newNote.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
|
||||
newNote.innerHTML = `[ <b>${data.note}</b> ]`;
|
||||
|
||||
const flexColumn = itemEl.querySelector('.d-flex.flex-column');
|
||||
if (flexColumn) {
|
||||
flexColumn.appendChild(newNote);
|
||||
} else {
|
||||
itemEl.appendChild(newNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
showToast('Notatka zaktualizowana!');
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
showToast('Notatka dodana/zaktualizowana', 'success');
|
||||
});
|
||||
|
||||
socket.on('item_edited', data => {
|
||||
const nameSpan = document.getElementById(`name-${data.item_id}`);
|
||||
if (nameSpan) {
|
||||
let quantityBadge = '';
|
||||
if (data.new_quantity && data.new_quantity > 1) {
|
||||
quantityBadge = ` <span class="badge bg-secondary">x${data.new_quantity}</span>`;
|
||||
}
|
||||
nameSpan.innerHTML = `${data.new_name}${quantityBadge}`;
|
||||
}
|
||||
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`);
|
||||
});
|
||||
const idx = window.currentItems.findIndex(i => i.id === data.item_id);
|
||||
if (idx !== -1) {
|
||||
window.currentItems[idx].name = data.new_name;
|
||||
window.currentItems[idx].quantity = data.new_quantity;
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
const newItem = renderItem(window.currentItems[idx], window.IS_SHARE);
|
||||
const oldItem = document.getElementById(`item-${data.item_id}`);
|
||||
if (oldItem && newItem) {
|
||||
oldItem.replaceWith(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success');
|
||||
|
||||
updateProgressBar();
|
||||
toggleEmptyPlaceholder();
|
||||
});
|
||||
|
||||
// --- WAŻNE: zapisz dane do reconnect ---
|
||||
window.LIST_ID = listId;
|
||||
window.usernameForReconnect = username;
|
||||
window.CURRENT_LIST_USERNAME = username;
|
||||
|
||||
}
|
||||
|
||||
function unmarkNotPurchased(itemId) {
|
||||
socket.emit('unmark_not_purchased', { item_id: itemId });
|
||||
}
|
||||
309
shopping_app/static/js/mass_add.js
Normal file
@@ -0,0 +1,309 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const modal = document.getElementById('massAddModal');
|
||||
const productList = document.getElementById('mass-add-list');
|
||||
const sortBar = document.getElementById('sort-bar');
|
||||
const productCountDisplay = document.getElementById('product-count');
|
||||
const modalBody = modal?.querySelector('.modal-body');
|
||||
|
||||
function normalize(str) {
|
||||
return str?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
let sortMode = 'popularity';
|
||||
let limit = 25;
|
||||
let offset = 0;
|
||||
let loading = false;
|
||||
let reachedEnd = false;
|
||||
let allProducts = [];
|
||||
let addedProducts = new Set();
|
||||
|
||||
function renderSortBar() {
|
||||
if (!sortBar) return;
|
||||
sortBar.innerHTML = `
|
||||
Sortuj: <a href="#" id="sort-popularity" ${sortMode === "popularity" ? 'style="font-weight:bold"' : ''}>Popularność</a> |
|
||||
<a href="#" id="sort-alphabetical" ${sortMode === "alphabetical" ? 'style="font-weight:bold"' : ''}>Alfabetycznie</a>
|
||||
`;
|
||||
document.getElementById('sort-popularity').onclick = (e) => {
|
||||
e.preventDefault();
|
||||
if (sortMode !== 'popularity') {
|
||||
sortMode = 'popularity';
|
||||
resetAndFetchProducts();
|
||||
}
|
||||
};
|
||||
document.getElementById('sort-alphabetical').onclick = (e) => {
|
||||
e.preventDefault();
|
||||
if (sortMode !== 'alphabetical') {
|
||||
sortMode = 'alphabetical';
|
||||
resetAndFetchProducts();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resetAndFetchProducts() {
|
||||
offset = 0;
|
||||
reachedEnd = false;
|
||||
allProducts = [];
|
||||
productList.innerHTML = '';
|
||||
fetchProducts(true);
|
||||
renderSortBar();
|
||||
if (productCountDisplay) productCountDisplay.textContent = '';
|
||||
}
|
||||
|
||||
async function fetchProducts(reset = false) {
|
||||
if (loading || reachedEnd) return;
|
||||
loading = true;
|
||||
|
||||
if (!reset) {
|
||||
const loadingLi = document.createElement('li');
|
||||
loadingLi.className = 'list-group-item bg-dark text-light loading';
|
||||
loadingLi.textContent = 'Ładowanie...';
|
||||
productList.appendChild(loadingLi);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/all_products?sort=${sortMode}&limit=${limit}&offset=${offset}`);
|
||||
const data = await res.json();
|
||||
const products = data.products || [];
|
||||
|
||||
if (products.length < limit) reachedEnd = true;
|
||||
allProducts = reset ? products : allProducts.concat(products);
|
||||
|
||||
const loadingEl = productList.querySelector('.loading');
|
||||
if (loadingEl) loadingEl.remove();
|
||||
|
||||
if (reset && products.length === 0) {
|
||||
const emptyLi = document.createElement('li');
|
||||
emptyLi.className = 'list-group-item text-muted bg-dark';
|
||||
emptyLi.textContent = 'Brak produktów do wyświetlenia.';
|
||||
productList.appendChild(emptyLi);
|
||||
} else {
|
||||
renderProducts(products);
|
||||
}
|
||||
|
||||
offset += limit;
|
||||
|
||||
if (productCountDisplay) {
|
||||
productCountDisplay.textContent = `Wyświetlono ${allProducts.length} z ${data.total_count} pozycji`;
|
||||
}
|
||||
|
||||
const statsEl = document.getElementById('massAddProductStats');
|
||||
if (statsEl) {
|
||||
statsEl.textContent = `(${allProducts.length} z ${data.total_count})`;
|
||||
}
|
||||
|
||||
|
||||
} catch (err) {
|
||||
const loadingEl = productList.querySelector('.loading');
|
||||
if (loadingEl) loadingEl.remove();
|
||||
const errorLi = document.createElement('li');
|
||||
errorLi.className = 'list-group-item text-danger bg-dark';
|
||||
errorLi.textContent = 'Błąd ładowania danych';
|
||||
productList.appendChild(errorLi);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function getAlreadyAddedProducts() {
|
||||
const set = new Set();
|
||||
document.querySelectorAll('#items li').forEach(li => {
|
||||
if (li.dataset.name) {
|
||||
set.add(normalize(li.dataset.name));
|
||||
}
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
function renderProducts(products) {
|
||||
addedProducts = getAlreadyAddedProducts();
|
||||
|
||||
const existingNames = new Set();
|
||||
document.querySelectorAll('#mass-add-list li').forEach(li => {
|
||||
const name = li.querySelector('span')?.textContent;
|
||||
if (name) existingNames.add(normalize(name));
|
||||
});
|
||||
|
||||
products.forEach(product => {
|
||||
const name = typeof product === "object" ? product.name : product;
|
||||
const normName = normalize(name);
|
||||
if (existingNames.has(normName)) return;
|
||||
existingNames.add(normName);
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center bg-dark text-light';
|
||||
|
||||
if (addedProducts.has(normName)) {
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = name;
|
||||
li.appendChild(nameSpan);
|
||||
li.classList.add('opacity-50');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-success ms-auto';
|
||||
badge.textContent = 'Dodano';
|
||||
li.appendChild(badge);
|
||||
} else {
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = name;
|
||||
nameSpan.style.flex = '1 1 auto';
|
||||
li.appendChild(nameSpan);
|
||||
|
||||
const qtyWrapper = document.createElement('div');
|
||||
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
|
||||
|
||||
const minusBtn = document.createElement('button');
|
||||
minusBtn.type = 'button';
|
||||
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
|
||||
minusBtn.textContent = '−';
|
||||
|
||||
const qty = document.createElement('input');
|
||||
qty.type = 'number';
|
||||
qty.min = 1;
|
||||
qty.value = 1;
|
||||
qty.className = 'form-control text-center p-1 rounded';
|
||||
qty.style.width = '50px';
|
||||
qty.style.margin = '0 2px';
|
||||
qty.title = 'Ilość';
|
||||
|
||||
const plusBtn = document.createElement('button');
|
||||
plusBtn.type = 'button';
|
||||
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
|
||||
plusBtn.textContent = '+';
|
||||
|
||||
minusBtn.onclick = () => {
|
||||
qty.value = Math.max(1, parseInt(qty.value) - 1);
|
||||
};
|
||||
plusBtn.onclick = () => {
|
||||
qty.value = parseInt(qty.value) + 1;
|
||||
};
|
||||
|
||||
qtyWrapper.append(minusBtn, qty, plusBtn);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-primary ms-4';
|
||||
btn.textContent = '+';
|
||||
btn.onclick = () => {
|
||||
const quantity = parseInt(qty.value) || 1;
|
||||
socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity });
|
||||
};
|
||||
|
||||
li.append(qtyWrapper, btn);
|
||||
}
|
||||
|
||||
productList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
if (modalBody) {
|
||||
modalBody.addEventListener('scroll', function () {
|
||||
if (!loading && !reachedEnd && (modalBody.scrollTop + modalBody.clientHeight > modalBody.scrollHeight - 80)) {
|
||||
fetchProducts(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
modal.addEventListener('show.bs.modal', function () {
|
||||
resetAndFetchProducts();
|
||||
});
|
||||
|
||||
renderSortBar();
|
||||
|
||||
socket.on('item_added', data => {
|
||||
document.querySelectorAll('#mass-add-list li').forEach(li => {
|
||||
const itemName = li.firstChild?.textContent.trim();
|
||||
if (normalize(itemName) === normalize(data.name) && !li.classList.contains('opacity-50')) {
|
||||
li.classList.add('opacity-50');
|
||||
li.querySelectorAll('button').forEach(btn => btn.remove());
|
||||
const quantityControls = li.querySelector('.quantity-controls');
|
||||
if (quantityControls) quantityControls.remove();
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-success';
|
||||
badge.textContent = 'Dodano';
|
||||
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.className = 'btn-group btn-group-sm me-2';
|
||||
btnGroup.role = 'group';
|
||||
|
||||
const undoBtn = document.createElement('button');
|
||||
undoBtn.className = 'btn btn-outline-warning';
|
||||
undoBtn.innerHTML = '⟳ Cofnij';
|
||||
|
||||
const timerBtn = document.createElement('button');
|
||||
timerBtn.className = 'btn btn-outline-secondary disabled';
|
||||
let secondsLeft = 15;
|
||||
timerBtn.textContent = `${secondsLeft}s`;
|
||||
|
||||
btnGroup.append(undoBtn, timerBtn);
|
||||
|
||||
const rightWrapper = document.createElement('div');
|
||||
rightWrapper.className = 'd-flex align-items-center gap-2 ms-auto';
|
||||
rightWrapper.append(btnGroup, badge);
|
||||
li.appendChild(rightWrapper);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
secondsLeft--;
|
||||
if (secondsLeft > 0) {
|
||||
timerBtn.textContent = `${secondsLeft}s`;
|
||||
} else {
|
||||
clearInterval(intervalId);
|
||||
btnGroup.remove();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
undoBtn.onclick = () => {
|
||||
clearInterval(intervalId);
|
||||
btnGroup.remove();
|
||||
badge.remove();
|
||||
li.classList.remove('opacity-50');
|
||||
|
||||
const qtyWrapper = document.createElement('div');
|
||||
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
|
||||
|
||||
const minusBtn = document.createElement('button');
|
||||
minusBtn.type = 'button';
|
||||
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
|
||||
minusBtn.textContent = '−';
|
||||
|
||||
const qty = document.createElement('input');
|
||||
qty.type = 'number';
|
||||
qty.min = 1;
|
||||
qty.value = 1;
|
||||
qty.className = 'form-control text-center p-1 rounded';
|
||||
qty.style.width = '50px';
|
||||
qty.style.margin = '0 2px';
|
||||
qty.title = 'Ilość';
|
||||
|
||||
const plusBtn = document.createElement('button');
|
||||
plusBtn.type = 'button';
|
||||
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
|
||||
plusBtn.textContent = '+';
|
||||
|
||||
minusBtn.onclick = () => {
|
||||
qty.value = Math.max(1, parseInt(qty.value) - 1);
|
||||
};
|
||||
plusBtn.onclick = () => {
|
||||
qty.value = parseInt(qty.value) + 1;
|
||||
};
|
||||
|
||||
qtyWrapper.append(minusBtn, qty, plusBtn);
|
||||
li.appendChild(qtyWrapper);
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'btn btn-sm btn-primary ms-4';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.onclick = () => {
|
||||
const quantity = parseInt(qty.value) || 1;
|
||||
socket.emit('add_item', {
|
||||
list_id: LIST_ID,
|
||||
name: data.name,
|
||||
quantity: quantity
|
||||
});
|
||||
};
|
||||
li.appendChild(addBtn);
|
||||
|
||||
socket.emit('delete_item', { item_id: data.id });
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
118
shopping_app/static/js/modal_chart.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// modal_chart.js — final: kopiuje kolory z oryginałów, bez fallbacków i bez debugów
|
||||
|
||||
function openChartFullscreen(sourceChartIdOrKey, title) {
|
||||
const modalEl = document.getElementById("chartFullscreenModal");
|
||||
const canvas = document.getElementById("chartFullscreenCanvas");
|
||||
const titleEl = document.getElementById("chartModalTitle");
|
||||
if (titleEl) titleEl.textContent = title || "Wykres";
|
||||
|
||||
// Znajdź wykres źródłowy (po elemencie, id Chart.js lub globalu)
|
||||
const srcEl = document.getElementById(sourceChartIdOrKey);
|
||||
const srcChart =
|
||||
(srcEl && Chart.getChart(srcEl)) ||
|
||||
Chart.getChart(sourceChartIdOrKey) ||
|
||||
window[sourceChartIdOrKey] ||
|
||||
window.expensesChart ||
|
||||
null;
|
||||
|
||||
if (!srcChart) {
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skopiuj labels i datasets 1:1 (tylko bezpieczne klucze, żeby nie przenosić referencji Chart.js)
|
||||
const safeDataset = (d) => {
|
||||
const out = {
|
||||
// dane i opis
|
||||
label: d.label,
|
||||
data: Array.isArray(d.data) ? d.data.slice() : [],
|
||||
type: d.type,
|
||||
// kolory / styl — dokładnie z oryginału, jeśli były
|
||||
backgroundColor: d.backgroundColor,
|
||||
borderColor: d.borderColor,
|
||||
borderWidth: d.borderWidth,
|
||||
borderSkipped: d.borderSkipped,
|
||||
// stacking / kolejność
|
||||
stack: d.stack,
|
||||
order: d.order,
|
||||
// wszystko co może być ważne dla Twoich barów/konfiguracji
|
||||
parsing: d.parsing,
|
||||
indexAxis: d.indexAxis,
|
||||
};
|
||||
// usuń klucze undefined (Chart.js lubi czyste configi)
|
||||
Object.keys(out).forEach((k) => out[k] === undefined && delete out[k]);
|
||||
return out;
|
||||
};
|
||||
|
||||
const freshData = {
|
||||
labels: Array.isArray(srcChart.data?.labels) ? srcChart.data.labels.slice() : [],
|
||||
datasets: (srcChart.data?.datasets || []).map(safeDataset),
|
||||
};
|
||||
|
||||
// Typ wykresu z oryginału (np. "bar")
|
||||
const chartType = (srcChart.config && srcChart.config.type) || "bar";
|
||||
|
||||
// Minimalne, bezpieczne opcje: responsywność + stacking + orientacja
|
||||
const scx = srcChart.config?.options?.scales?.x || {};
|
||||
const scy = srcChart.config?.options?.scales?.y || {};
|
||||
|
||||
const freshOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
// jeżeli oryginał miał pion/poziom, zachowaj
|
||||
indexAxis: srcChart.config?.options?.indexAxis || "x",
|
||||
// nie kopiujemy całych pluginów (unikamy referencji) — domyślne legend/tooltip są OK
|
||||
plugins: {},
|
||||
scales: {
|
||||
x: { stacked: !!scx.stacked },
|
||||
y: { stacked: !!scy.stacked, beginAtZero: scy.beginAtZero !== false },
|
||||
},
|
||||
};
|
||||
|
||||
// Helper: zniszcz wykres na canvasie modala, jeśli istnieje
|
||||
const destroyOnCanvas = () => {
|
||||
if (canvas._chartInstance) {
|
||||
try { canvas._chartInstance.destroy(); } catch { }
|
||||
canvas._chartInstance = null;
|
||||
}
|
||||
const existing = Chart.getChart(canvas);
|
||||
if (existing) {
|
||||
try { existing.destroy(); } catch { }
|
||||
}
|
||||
};
|
||||
destroyOnCanvas();
|
||||
|
||||
// Po pokazaniu modala twórz wykres (gdy ma już wymiary)
|
||||
const onShown = () => {
|
||||
destroyOnCanvas();
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas._chartInstance = new Chart(ctx, {
|
||||
type: chartType,
|
||||
data: freshData,
|
||||
options: freshOptions,
|
||||
});
|
||||
// lekki nudge layoutu
|
||||
requestAnimationFrame(() => {
|
||||
canvas._chartInstance.resize();
|
||||
canvas._chartInstance.update();
|
||||
});
|
||||
};
|
||||
|
||||
const onHidden = () => { destroyOnCanvas(); };
|
||||
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
modalEl.addEventListener("shown.bs.modal", onShown, { once: true });
|
||||
modalEl.addEventListener("hidden.bs.modal", onHidden, { once: true });
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Odblokuj ⛶ gdy bazowy wykres gotowy
|
||||
document.addEventListener("expensesChart:ready", () => {
|
||||
const b = document.getElementById("openFsBtn");
|
||||
if (b) b.disabled = false;
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const b = document.getElementById("openFsBtn");
|
||||
if (b && window.expensesChart) b.disabled = false;
|
||||
});
|
||||
24
shopping_app/static/js/notes.js
Normal file
@@ -0,0 +1,24 @@
|
||||
window.currentItemId = window.currentItemId ?? null;
|
||||
|
||||
window.openNoteModal = function (event, itemId) {
|
||||
event.stopPropagation();
|
||||
window.currentItemId = itemId;
|
||||
const noteEl = document.querySelector(`#item-${itemId} small.text-danger`);
|
||||
document.getElementById('noteText').value = noteEl
|
||||
? noteEl.innerText.replace(/\[|\]|Powód:/g, "").trim()
|
||||
: "";
|
||||
const modal = new bootstrap.Modal(document.getElementById('noteModal'));
|
||||
modal.show();
|
||||
};
|
||||
|
||||
function submitNote(e) {
|
||||
e.preventDefault();
|
||||
const text = document.getElementById('noteText').value;
|
||||
|
||||
if (window.currentItemId !== null) {
|
||||
socket.emit('update_note', { item_id: window.currentItemId, note: text });
|
||||
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('noteModal'));
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
112
shopping_app/static/js/preview_list_modal.js
Normal file
@@ -0,0 +1,112 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const modalElement = document.getElementById("productPreviewModal");
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
|
||||
modalElement.addEventListener("hidden.bs.modal", function () {
|
||||
document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove());
|
||||
document.body.classList.remove("modal-open");
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
document.querySelectorAll(".preview-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const listId = btn.dataset.listId;
|
||||
const modalTitle = document.getElementById("previewModalLabel");
|
||||
const productList = document.getElementById("product-list");
|
||||
|
||||
modalTitle.textContent = "Ładowanie...";
|
||||
productList.innerHTML = `
|
||||
<li class="list-group-item bg-dark text-white">
|
||||
⏳ Ładowanie produktów...
|
||||
</li>`;
|
||||
|
||||
modal.show();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/admin/list_items/${listId}`);
|
||||
const data = await res.json();
|
||||
|
||||
modalTitle.textContent = `🛒 ${data.title}`;
|
||||
productList.innerHTML = "";
|
||||
|
||||
// 🔢 PODSUMOWANIE
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "mb-3";
|
||||
|
||||
const percent =
|
||||
data.total_count > 0
|
||||
? Math.round((data.purchased_count / data.total_count) * 100)
|
||||
: 0;
|
||||
|
||||
summary.innerHTML = `
|
||||
<p class="mb-1">📦 <strong>${data.total_count}</strong> produktów</p>
|
||||
<p class="mb-1">✅ Kupione: <strong>${data.purchased_count}</strong> (${percent}%)</p>
|
||||
<p class="mb-1">💸 Wydatek: <strong>${data.total_expense.toFixed(2)} zł</strong></p>
|
||||
<hr class="my-2">
|
||||
`;
|
||||
productList.appendChild(summary);
|
||||
|
||||
// 🛒 LISTY PRODUKTÓW
|
||||
const purchasedList = document.createElement("ul");
|
||||
purchasedList.className = "list-group list-group-flush mb-3";
|
||||
|
||||
const notPurchasedList = document.createElement("ul");
|
||||
notPurchasedList.className = "list-group list-group-flush";
|
||||
|
||||
let hasPurchased = false;
|
||||
let hasUnpurchased = false;
|
||||
|
||||
data.items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.className =
|
||||
"list-group-item bg-dark text-white d-flex justify-content-between";
|
||||
li.innerHTML = `
|
||||
<span>${item.name}</span>
|
||||
<span class="badge ${item.purchased
|
||||
? "bg-success"
|
||||
: item.not_purchased
|
||||
? "bg-warning text-dark"
|
||||
: "bg-secondary"
|
||||
}">
|
||||
x${item.quantity}
|
||||
</span>`;
|
||||
|
||||
if (item.purchased) {
|
||||
purchasedList.appendChild(li);
|
||||
hasPurchased = true;
|
||||
} else {
|
||||
notPurchasedList.appendChild(li);
|
||||
hasUnpurchased = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasPurchased) {
|
||||
const h5 = document.createElement("h6");
|
||||
h5.textContent = "✔️ Kupione";
|
||||
productList.appendChild(h5);
|
||||
productList.appendChild(purchasedList);
|
||||
}
|
||||
|
||||
if (hasUnpurchased) {
|
||||
const h5 = document.createElement("h6");
|
||||
h5.textContent = "🚫 Niekupione / Nieoznaczone";
|
||||
productList.appendChild(h5);
|
||||
productList.appendChild(notPurchasedList);
|
||||
}
|
||||
|
||||
if (!hasPurchased && !hasUnpurchased) {
|
||||
productList.innerHTML = `
|
||||
<li class="list-group-item bg-dark text-muted fst-italic">
|
||||
Brak produktów
|
||||
</li>`;
|
||||
}
|
||||
} catch (err) {
|
||||
modalTitle.textContent = "Błąd";
|
||||
productList.innerHTML = `
|
||||
<li class="list-group-item bg-dark text-danger">
|
||||
❌ Błąd podczas ładowania
|
||||
</li>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
91
shopping_app/static/js/product_suggestion.js
Normal file
@@ -0,0 +1,91 @@
|
||||
function bindSyncButton(button) {
|
||||
button.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const itemId = button.getAttribute('data-item-id');
|
||||
button.disabled = true;
|
||||
|
||||
fetch(`/admin/sync_suggestion/${itemId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
showToast(data.message, data.success ? 'success' : 'danger');
|
||||
|
||||
if (data.success) {
|
||||
button.innerText = '✅ Zsynchronizowano';
|
||||
button.classList.remove('btn-outline-primary');
|
||||
button.classList.add('btn-success');
|
||||
} else {
|
||||
button.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('Błąd synchronizacji', 'danger');
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindDeleteButton(button) {
|
||||
button.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const suggestionId = button.getAttribute('data-suggestion-id');
|
||||
const row = button.closest('tr');
|
||||
const itemId = button.getAttribute('data-item-id');
|
||||
const nameBadge = row?.querySelector('.badge.bg-primary');
|
||||
const itemName = nameBadge?.innerText.trim().toLowerCase();
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
fetch(`/admin/delete_suggestion/${suggestionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
showToast(data.message, data.success ? 'success' : 'danger');
|
||||
|
||||
if (!data.success || !row) {
|
||||
button.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isProductRow = typeof itemId === 'string' && itemId !== '';
|
||||
const cell = row.querySelector('td:last-child');
|
||||
if (!cell) return;
|
||||
|
||||
if (isProductRow) {
|
||||
cell.innerHTML = `<button class="btn btn-sm btn-outline-light sync-btn" data-item-id="${itemId}">🔄 Synchronizuj</button>`;
|
||||
const syncBtn = cell.querySelector('.sync-btn');
|
||||
if (syncBtn) bindSyncButton(syncBtn);
|
||||
} else {
|
||||
cell.innerHTML = '<span class="badge rounded-pill bg-warning opacity-75">Usunięto z bazy danych</span>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('Błąd usuwania sugestii', 'danger');
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll('.sync-btn').forEach(btn => {
|
||||
const clone = btn.cloneNode(true);
|
||||
btn.replaceWith(clone);
|
||||
bindSyncButton(clone);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.delete-suggestion-btn').forEach(btn => {
|
||||
const clone = btn.cloneNode(true);
|
||||
btn.replaceWith(clone);
|
||||
bindDeleteButton(clone);
|
||||
});
|
||||
});
|
||||
99
shopping_app/static/js/receipt_analysis.js
Normal file
@@ -0,0 +1,99 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const analyzeBtn = document.getElementById("analyzeBtn");
|
||||
if (analyzeBtn) {
|
||||
analyzeBtn.addEventListener("click", () => analyzeReceipts(LIST_ID));
|
||||
}
|
||||
});
|
||||
|
||||
async function analyzeReceipts(listId) {
|
||||
const resultsDiv = document.getElementById("analysisResults");
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="text-info d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
|
||||
<span>Trwa analiza paragonów...</span>
|
||||
</div>`;
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
const duration = ((performance.now() - start) / 1000).toFixed(2);
|
||||
|
||||
let html = `<div class="card bg-dark text-white border-secondary p-3">`;
|
||||
html += `<p class="text-secondary"><small>⏱ Czas analizy OCR: ${duration} sek.</small></p>`;
|
||||
html += `<p><b>📊 Łącznie wykryto:</b> ${data.total.toFixed(2)} PLN</p>`;
|
||||
|
||||
data.results.forEach((r, i) => {
|
||||
const disabled = r.already_added ? "disabled" : "";
|
||||
const inputStyle = "form-control d-inline-block bg-dark text-white border-light rounded";
|
||||
const inputField = `<input type="number" id="amount-${i}" value="${r.amount}" step="0.01" class="${inputStyle}" style="width: 120px;" ${disabled}>`;
|
||||
|
||||
const button = r.already_added
|
||||
? `<span class="badge rounded-pill bg-secondary ms-2">Dodano</span>`
|
||||
: `<button id="add-btn-${i}" onclick="emitExpense(${i})" class="btn btn-outline-light ms-2">➕ Dodaj</button>`;
|
||||
|
||||
html += `
|
||||
<div class="mb-2 d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="text-light flex-grow-1">${r.filename}</span>
|
||||
${inputField}
|
||||
${button}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
|
||||
if (data.results.length > 1) {
|
||||
html += `<button id="addAllBtn" onclick="emitAllExpenses(${data.results.length})" class="btn btn-sm btn-outline-light mt-3 w-100">➕ Dodaj wszystkie</button>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
resultsDiv.innerHTML = html;
|
||||
window._ocr_results = data.results;
|
||||
|
||||
} catch (err) {
|
||||
resultsDiv.innerHTML = `<div class="text-danger">❌ Wystąpił błąd podczas analizy.</div>`;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function emitExpense(i) {
|
||||
const r = window._ocr_results[i];
|
||||
const val = parseFloat(document.getElementById(`amount-${i}`).value);
|
||||
const btn = document.getElementById(`add-btn-${i}`);
|
||||
|
||||
if (!isNaN(val) && val > 0) {
|
||||
socket.emit('add_expense', {
|
||||
list_id: LIST_ID,
|
||||
amount: val,
|
||||
receipt_filename: r.filename
|
||||
});
|
||||
|
||||
document.getElementById(`amount-${i}`).disabled = true;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.classList.remove('btn-outline-success');
|
||||
btn.classList.add('btn-success');
|
||||
btn.textContent = '✅ Dodano';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitAllExpenses(n) {
|
||||
const btnAll = document.getElementById('addAllBtn');
|
||||
if (btnAll) {
|
||||
btnAll.disabled = true;
|
||||
btnAll.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status"></span>Dodawanie...`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
setTimeout(() => emitExpense(i), i * 150);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (btnAll) {
|
||||
btnAll.innerHTML = '✅ Wszystko dodano';
|
||||
btnAll.classList.remove('btn-success');
|
||||
btnAll.classList.add('btn-outline-success');
|
||||
}
|
||||
}, n * 150 + 300);
|
||||
}
|
||||
57
shopping_app/static/js/receipt_crop.js
Normal file
@@ -0,0 +1,57 @@
|
||||
(function () {
|
||||
const configs = (window.CROP_CONFIGS && Array.isArray(window.CROP_CONFIGS))
|
||||
? window.CROP_CONFIGS
|
||||
: (window.CROP_CONFIG ? [window.CROP_CONFIG] : []);
|
||||
|
||||
if (!configs.length) return;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
configs.forEach((cfg) => initCropperSet(cfg));
|
||||
});
|
||||
|
||||
function initCropperSet(cfg) {
|
||||
const {
|
||||
modalId,
|
||||
imageId,
|
||||
spinnerId,
|
||||
saveBtnId,
|
||||
endpoint
|
||||
} = cfg || {};
|
||||
|
||||
const cropModal = document.getElementById(modalId);
|
||||
const cropImage = document.getElementById(imageId);
|
||||
const spinner = document.getElementById(spinnerId);
|
||||
const saveButton = document.getElementById(saveBtnId);
|
||||
|
||||
if (!cropModal || !cropImage || !spinner || !saveButton) return;
|
||||
|
||||
let cropper;
|
||||
let currentReceiptId;
|
||||
const currentEndpoint = endpoint;
|
||||
|
||||
cropModal.addEventListener("shown.bs.modal", function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const baseSrc = button?.getAttribute("data-img-src") || "";
|
||||
const ver = button?.getAttribute("data-version") || Date.now();
|
||||
const sep = baseSrc.includes("?") ? "&" : "?";
|
||||
cropImage.src = baseSrc + sep + "cb=" + ver;
|
||||
|
||||
currentReceiptId = button?.getAttribute("data-receipt-id");
|
||||
|
||||
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
|
||||
if (cropper && cropper.destroy) cropper.destroy();
|
||||
cropImage.onload = () => { cropper = cropUtils.initCropper(cropImage); };
|
||||
});
|
||||
|
||||
cropModal.addEventListener("hidden.bs.modal", function () {
|
||||
cropUtils.cleanUpCropper(cropImage, cropper);
|
||||
cropper = null;
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", function () {
|
||||
if (!cropper) return;
|
||||
spinner.classList.remove("d-none");
|
||||
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
|
||||
});
|
||||
}
|
||||
})();
|
||||
96
shopping_app/static/js/receipt_crop_logic.js
Normal file
@@ -0,0 +1,96 @@
|
||||
(function () {
|
||||
function initCropper(imgEl) {
|
||||
return new Cropper(imgEl, {
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true,
|
||||
background: false,
|
||||
zoomable: true,
|
||||
movable: true,
|
||||
dragMode: 'move',
|
||||
minContainerHeight: 400,
|
||||
minContainerWidth: 400,
|
||||
});
|
||||
}
|
||||
|
||||
function cleanUpCropper(imgEl, cropperInstance) {
|
||||
if (cropperInstance) {
|
||||
cropperInstance.destroy();
|
||||
}
|
||||
if (imgEl) imgEl.src = "";
|
||||
}
|
||||
|
||||
function handleCrop(endpoint, receiptId, cropper, spinner) {
|
||||
const cropData = cropper.getData();
|
||||
const imageData = cropper.getImageData();
|
||||
|
||||
const scaleX = imageData.naturalWidth / imageData.width;
|
||||
const scaleY = imageData.naturalHeight / imageData.height;
|
||||
|
||||
const width = cropData.width * scaleX;
|
||||
const height = cropData.height * scaleY;
|
||||
|
||||
if (width < 1 || height < 1) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const maxDim = 2000;
|
||||
const scale = Math.min(1, maxDim / Math.max(width, height));
|
||||
|
||||
const finalWidth = Math.round(width * scale);
|
||||
const finalHeight = Math.round(height * scale);
|
||||
|
||||
const croppedCanvas = cropper.getCroppedCanvas({
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
|
||||
if (!croppedCanvas) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Nie można uzyskać obrazu przycięcia", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
croppedCanvas.toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Nie udało się zapisać obrazu", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("receipt_id", receiptId);
|
||||
formData.append("cropped_image", blob);
|
||||
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
spinner.classList.add("d-none");
|
||||
if (data.success) {
|
||||
showToast("Zapisano przycięty paragon", "success");
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast("Błąd: " + (data.error || "Nieznany"), "danger");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
spinner.classList.add("d-none");
|
||||
showToast("Błąd sieci", "danger");
|
||||
console.error(err);
|
||||
});
|
||||
}, "image/webp", 1.0);
|
||||
}
|
||||
|
||||
window.cropUtils = {
|
||||
initCropper,
|
||||
cleanUpCropper,
|
||||
handleCrop,
|
||||
};
|
||||
})();
|
||||
39
shopping_app/static/js/receipt_section.js
Normal file
@@ -0,0 +1,39 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const receiptSection = document.getElementById("receiptSection");
|
||||
const toggleBtn = document.querySelector('[data-bs-target="#receiptSection"]');
|
||||
|
||||
if (!receiptSection || !toggleBtn) return;
|
||||
|
||||
if (localStorage.getItem("receiptSectionOpen") === "true") {
|
||||
new bootstrap.Collapse(receiptSection, { toggle: true });
|
||||
}
|
||||
|
||||
receiptSection.addEventListener('shown.bs.collapse', function () {
|
||||
localStorage.setItem("receiptSectionOpen", "true");
|
||||
});
|
||||
|
||||
receiptSection.addEventListener('hidden.bs.collapse', function () {
|
||||
localStorage.setItem("receiptSectionOpen", "false");
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const btn = document.getElementById("toggleReceiptBtn");
|
||||
const target = document.querySelector(btn.getAttribute("data-bs-target"));
|
||||
|
||||
function updateUI() {
|
||||
const isShown = target.classList.contains("show");
|
||||
btn.innerHTML = isShown
|
||||
? "📄 Ukryj sekcję paragonów"
|
||||
: "📄 Pokaż sekcję paragonów";
|
||||
|
||||
btn.classList.toggle("active", isShown);
|
||||
btn.classList.toggle("btn-outline-light", !isShown);
|
||||
btn.classList.toggle("btn-secondary", isShown);
|
||||
}
|
||||
|
||||
target.addEventListener("shown.bs.collapse", updateUI);
|
||||
target.addEventListener("hidden.bs.collapse", updateUI);
|
||||
|
||||
updateUI();
|
||||
});
|
||||
@@ -3,29 +3,28 @@ window.receiptToastShown = window.receiptToastShown || false;
|
||||
if (!window.receiptUploaderInitialized) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const form = document.getElementById("receiptForm");
|
||||
const input = document.getElementById("receiptInput");
|
||||
const gallery = document.getElementById("receiptGallery");
|
||||
const inputCamera = document.getElementById("cameraInput");
|
||||
const inputGallery = document.getElementById("galleryInput");
|
||||
const inputPDF = document.getElementById("pdfInput");
|
||||
const galleryBtn = document.getElementById("galleryBtn");
|
||||
const galleryBtnText = document.getElementById("galleryBtnText");
|
||||
const cameraBtn = document.getElementById("cameraBtn");
|
||||
const progressContainer = document.getElementById("progressContainer");
|
||||
const progressBar = document.getElementById("progressBar");
|
||||
const fileLabel = document.getElementById("fileLabel");
|
||||
const gallery = document.getElementById("receiptGallery");
|
||||
|
||||
if (!form || !input || !gallery) return;
|
||||
if (!form || !gallery) return;
|
||||
|
||||
// Zmiana labela po wyborze pliku
|
||||
if (input && fileLabel) {
|
||||
input.addEventListener("change", function () {
|
||||
if (input.files.length > 0) {
|
||||
fileLabel.textContent = input.files[0].name;
|
||||
} else {
|
||||
fileLabel.textContent = "Wybierz zdjęcie paragonu";
|
||||
}
|
||||
});
|
||||
const isDesktop = window.matchMedia("(pointer: fine)").matches;
|
||||
|
||||
if (isDesktop) {
|
||||
if (cameraBtn) cameraBtn.remove();
|
||||
if (inputCamera) inputCamera.remove();
|
||||
if (galleryBtnText) galleryBtnText.textContent = "➕ Dodaj paragon";
|
||||
}
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = input.files[0];
|
||||
function handleFileUpload(inputElement) {
|
||||
const file = inputElement.files[0];
|
||||
if (!file) {
|
||||
showToast("Nie wybrano pliku!", "warning");
|
||||
return;
|
||||
@@ -56,31 +55,35 @@ if (!window.receiptUploaderInitialized) {
|
||||
progressContainer.style.display = "none";
|
||||
progressBar.style.width = "0%";
|
||||
progressBar.textContent = "";
|
||||
input.value = "";
|
||||
|
||||
if (fileLabel) {
|
||||
fileLabel.textContent = "Wybierz zdjęcie paragonu";
|
||||
}
|
||||
inputElement.value = "";
|
||||
window.receiptToastShown = false;
|
||||
};
|
||||
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.success && res.url) {
|
||||
|
||||
if (xhr.status === 200 && res.success && res.url) {
|
||||
fetch(window.location.href)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const newGallery = doc.getElementById("receiptGallery");
|
||||
|
||||
if (newGallery) {
|
||||
gallery.innerHTML = newGallery.innerHTML;
|
||||
|
||||
lightbox.destroy();
|
||||
lightbox = GLightbox({
|
||||
selector: '.glightbox'
|
||||
});
|
||||
if (typeof lightbox !== "undefined") {
|
||||
lightbox.destroy();
|
||||
}
|
||||
lightbox = GLightbox({ selector: ".glightbox" });
|
||||
|
||||
const analysisBlock = document.getElementById("receiptAnalysisBlock");
|
||||
if (analysisBlock) {
|
||||
analysisBlock.classList.remove("d-none");
|
||||
}
|
||||
|
||||
if (!window.receiptToastShown) {
|
||||
showToast("Wgrano paragon", "success");
|
||||
@@ -89,16 +92,21 @@ if (!window.receiptUploaderInitialized) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
showToast(res.message || "Błąd podczas wgrywania.", "danger");
|
||||
const errorMessage = res.error || res.message || "Błąd podczas wgrywania.";
|
||||
showToast(errorMessage, "danger");
|
||||
}
|
||||
} else {
|
||||
showToast("Błąd serwera. Spróbuj ponownie.", "danger");
|
||||
} catch (err) {
|
||||
showToast("Błąd serwera (nieprawidłowa odpowiedź).", "danger");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
inputCamera?.addEventListener("change", () => handleFileUpload(inputCamera));
|
||||
inputGallery?.addEventListener("change", () => handleFileUpload(inputGallery));
|
||||
inputPDF?.addEventListener("change", () => handleFileUpload(inputPDF));
|
||||
});
|
||||
|
||||
window.receiptUploaderInitialized = true;
|
||||
12
shopping_app/static/js/select.js
Normal file
@@ -0,0 +1,12 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
new TomSelect("#categories", {
|
||||
plugins: ['remove_button'],
|
||||
maxItems: 1,
|
||||
placeholder: 'Wybierz jedną kategorie...',
|
||||
create: false,
|
||||
sortField: {
|
||||
field: "text",
|
||||
direction: "asc"
|
||||
}
|
||||
});
|
||||
});
|
||||
35
shopping_app/static/js/select_all_table.js
Normal file
@@ -0,0 +1,35 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const checkboxes = document.querySelectorAll('.list-checkbox');
|
||||
const totalEl = document.getElementById('listsTotal');
|
||||
const selectAllBtn = document.getElementById('selectAllBtn');
|
||||
const deselectAllBtn = document.getElementById('deselectAllBtn');
|
||||
|
||||
function updateTotal() {
|
||||
let total = 0;
|
||||
checkboxes.forEach(cb => {
|
||||
const row = cb.closest('tr');
|
||||
if (cb.checked && row.style.display !== 'none') {
|
||||
total += parseFloat(cb.dataset.amount);
|
||||
}
|
||||
});
|
||||
totalEl.textContent = total.toFixed(2) + ' PLN';
|
||||
}
|
||||
|
||||
selectAllBtn.addEventListener('click', () => {
|
||||
checkboxes.forEach(cb => cb.checked = true);
|
||||
updateTotal();
|
||||
selectAllBtn.style.display = 'none';
|
||||
deselectAllBtn.style.display = 'inline-block';
|
||||
});
|
||||
|
||||
deselectAllBtn.addEventListener('click', () => {
|
||||
checkboxes.forEach(cb => cb.checked = false);
|
||||
updateTotal();
|
||||
deselectAllBtn.style.display = 'none';
|
||||
selectAllBtn.style.display = 'inline-block';
|
||||
});
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
cb.addEventListener('change', updateTotal);
|
||||
});
|
||||
});
|
||||
14
shopping_app/static/js/select_month.js
Normal file
@@ -0,0 +1,14 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const select = document.getElementById("monthSelect");
|
||||
if (!select) return;
|
||||
select.addEventListener("change", () => {
|
||||
const month = select.value;
|
||||
const url = new URL(window.location.href);
|
||||
if (month) {
|
||||
url.searchParams.set("m", month);
|
||||
} else {
|
||||
url.searchParams.delete("m");
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
});
|
||||
17
shopping_app/static/js/show_all_expense.js
Normal file
@@ -0,0 +1,17 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const showAllCheckbox = document.getElementById('showAllLists');
|
||||
if (!showAllCheckbox) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.has('show_all')) {
|
||||
params.set('show_all', 'true');
|
||||
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
|
||||
}
|
||||
showAllCheckbox.checked = params.get('show_all') === 'true';
|
||||
|
||||
showAllCheckbox.addEventListener('change', function () {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.set('show_all', showAllCheckbox.checked ? 'true' : 'false');
|
||||
window.location.search = urlParams.toString();
|
||||
});
|
||||
});
|
||||
146
shopping_app/static/js/sockets.js
Normal file
@@ -0,0 +1,146 @@
|
||||
let didReceiveFirstFullList = false;
|
||||
|
||||
// --- Automatyczny reconnect po powrocie do karty/przywróceniu internetu ---
|
||||
function reconnectIfNeeded() {
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!document.hidden) {
|
||||
reconnectIfNeeded();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("focus", function () {
|
||||
reconnectIfNeeded();
|
||||
});
|
||||
|
||||
window.addEventListener("online", function () {
|
||||
reconnectIfNeeded();
|
||||
});
|
||||
|
||||
// --- Blokowanie checkboxów na czas reconnect ---
|
||||
function disableCheckboxes(disable) {
|
||||
document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => {
|
||||
cb.disabled = disable;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Toasty przy rozłączeniu i połączeniu ---
|
||||
let firstConnect = true;
|
||||
let wasReconnected = false; // flaga do kontrolowania toasta
|
||||
|
||||
socket.on('connect', function () {
|
||||
if (!firstConnect) {
|
||||
//showToast('Połączono z serwerem!', 'info');
|
||||
disableCheckboxes(true);
|
||||
wasReconnected = true;
|
||||
|
||||
if (window.LIST_ID && window.usernameForReconnect) {
|
||||
socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect });
|
||||
}
|
||||
}
|
||||
firstConnect = false;
|
||||
});
|
||||
|
||||
socket.on('disconnect', function (reason) {
|
||||
//showToast('Utracono połączenie z serwerem...', 'warning');
|
||||
disableCheckboxes(true);
|
||||
});
|
||||
|
||||
socket.off('joined_confirmation');
|
||||
socket.on('joined_confirmation', function (data) {
|
||||
if (wasReconnected) {
|
||||
showToast(`Lista: ${data.list_title} – ponownie dołączono.`, 'info');
|
||||
wasReconnected = false;
|
||||
}
|
||||
if (window.LIST_ID) {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
socket.on('user_joined', function (data) {
|
||||
showToast(`${data.username} dołączył do listy`, 'info');
|
||||
});
|
||||
|
||||
socket.on('user_left', function (data) {
|
||||
showToast(`${data.username} opuścił listę`, 'warning');
|
||||
});
|
||||
|
||||
socket.on('user_list', function (data) {
|
||||
if (data.users.length > 0) {
|
||||
const userList = data.users.join(', ');
|
||||
showToast(`Obecni: ${userList}`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('receipt_added', function (data) {
|
||||
|
||||
const gallery = document.getElementById("receiptGallery");
|
||||
if (!gallery) return;
|
||||
|
||||
const alert = gallery.querySelector(".alert");
|
||||
if (alert) {
|
||||
alert.remove();
|
||||
}
|
||||
|
||||
const existing = Array.from(gallery.querySelectorAll("img")).find(img => img.src === data.url);
|
||||
if (!existing) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "col-6 col-md-4 col-lg-3 text-center";
|
||||
col.innerHTML = `
|
||||
<a href="${data.url}" class="glightbox" data-gallery="receipt-gallery">
|
||||
<img src="${data.url}" class="img-fluid rounded shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
|
||||
</a>
|
||||
`;
|
||||
gallery.appendChild(col);
|
||||
lightbox.reload();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("items_reordered", data => {
|
||||
if (data.list_id !== window.LIST_ID) return;
|
||||
|
||||
if (window.currentItems) {
|
||||
window.currentItems = data.order.map(id =>
|
||||
window.currentItems.find(item => item.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
updateListSmoothly(window.currentItems);
|
||||
//showToast('Kolejność produktów zaktualizowana', 'info');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
socket.on('full_list', function (data) {
|
||||
const itemsContainer = document.getElementById('items');
|
||||
|
||||
const oldItems = Array.from(
|
||||
itemsContainer.querySelectorAll('li[data-name], li[id^="item-"]')
|
||||
);
|
||||
|
||||
const isDifferent = isListDifferent(oldItems, data.items);
|
||||
|
||||
window.currentItems = data.items;
|
||||
updateListSmoothly(data.items);
|
||||
if (typeof window.syncSortModeUI === 'function') {
|
||||
window.syncSortModeUI();
|
||||
}
|
||||
toggleEmptyPlaceholder();
|
||||
|
||||
if (didReceiveFirstFullList && isDifferent) {
|
||||
showToast('Lista została zaktualizowana', 'info');
|
||||
}
|
||||
didReceiveFirstFullList = true;
|
||||
});
|
||||
|
||||
socket.on('item_marked_not_purchased', data => {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
});
|
||||
|
||||
socket.on('item_unmarked_not_purchased', data => {
|
||||
socket.emit('request_full_list', { list_id: window.LIST_ID });
|
||||
});
|
||||
105
shopping_app/static/js/sort_mode.js
Normal file
@@ -0,0 +1,105 @@
|
||||
let sortable = null;
|
||||
window.isSorting = false;
|
||||
|
||||
function syncSortModeUI() {
|
||||
const active = !!window.isSorting;
|
||||
const btn = document.getElementById('sort-toggle-btn');
|
||||
const itemsContainer = document.getElementById('items');
|
||||
|
||||
document.body.classList.toggle('sorting-active', active);
|
||||
|
||||
if (btn) {
|
||||
if (active) {
|
||||
btn.textContent = '✔️ Zakończ sortowanie';
|
||||
btn.classList.remove('btn-outline-warning');
|
||||
btn.classList.add('btn-outline-success');
|
||||
} else {
|
||||
btn.textContent = '✳️ Zmień kolejność';
|
||||
btn.classList.remove('btn-outline-success');
|
||||
btn.classList.add('btn-outline-warning');
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsContainer && window.currentItems) {
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.drag-handle').forEach(handle => {
|
||||
handle.hidden = !active;
|
||||
handle.setAttribute('aria-hidden', active ? 'false' : 'true');
|
||||
});
|
||||
}
|
||||
|
||||
function enableSortMode() {
|
||||
if (window.isSorting) return;
|
||||
|
||||
const itemsContainer = document.getElementById('items');
|
||||
const listId = window.LIST_ID;
|
||||
if (!itemsContainer || !listId) return;
|
||||
|
||||
window.isSorting = true;
|
||||
syncSortModeUI();
|
||||
|
||||
setTimeout(() => {
|
||||
if (!window.isSorting) return;
|
||||
|
||||
if (sortable) {
|
||||
sortable.destroy();
|
||||
sortable = null;
|
||||
}
|
||||
|
||||
sortable = Sortable.create(itemsContainer, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'drag-ghost',
|
||||
filter: 'input, button:not(.drag-handle)',
|
||||
preventOnFilter: false,
|
||||
onEnd: () => {
|
||||
const order = Array.from(itemsContainer.children)
|
||||
.map(li => parseInt(li.id.replace('item-', ''), 10))
|
||||
.filter(id => !isNaN(id));
|
||||
|
||||
fetch('/reorder_items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ list_id: listId, order })
|
||||
}).then(() => {
|
||||
showToast('Zapisano nową kolejność', 'success');
|
||||
|
||||
if (window.currentItems) {
|
||||
window.currentItems = order
|
||||
.map(id => window.currentItems.find(item => item.id === id))
|
||||
.filter(Boolean);
|
||||
updateListSmoothly(window.currentItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function disableSortMode() {
|
||||
if (sortable) {
|
||||
sortable.destroy();
|
||||
sortable = null;
|
||||
}
|
||||
|
||||
window.isSorting = false;
|
||||
syncSortModeUI();
|
||||
}
|
||||
|
||||
function toggleSortMode() {
|
||||
if (window.isSorting) {
|
||||
disableSortMode();
|
||||
} else {
|
||||
enableSortMode();
|
||||
}
|
||||
}
|
||||
|
||||
window.toggleSortMode = toggleSortMode;
|
||||
window.syncSortModeUI = syncSortModeUI;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.isSorting = false;
|
||||
syncSortModeUI();
|
||||
});
|
||||
28
shopping_app/static/js/table_search.js
Normal file
@@ -0,0 +1,28 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const searchInput = document.getElementById("search-table");
|
||||
const clearButton = document.getElementById("clear-search");
|
||||
const rows = document.querySelectorAll("table tbody tr");
|
||||
|
||||
if (!searchInput || !rows.length) return;
|
||||
|
||||
function filterTable(query) {
|
||||
const q = query.toLowerCase();
|
||||
|
||||
rows.forEach(row => {
|
||||
const rowText = row.textContent.toLowerCase();
|
||||
row.style.display = rowText.includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
searchInput.addEventListener("input", function () {
|
||||
filterTable(this.value);
|
||||
});
|
||||
|
||||
if (clearButton) {
|
||||
clearButton.addEventListener("click", function () {
|
||||
searchInput.value = "";
|
||||
filterTable(""); // Pokaż wszystko
|
||||
searchInput.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
30
shopping_app/static/js/toggle_button.js
Normal file
@@ -0,0 +1,30 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toggleBtn = document.getElementById("tempToggle");
|
||||
const hiddenInput = document.getElementById("temporaryHidden");
|
||||
if (!toggleBtn || !hiddenInput) return;
|
||||
|
||||
if (typeof bootstrap !== "undefined") {
|
||||
new bootstrap.Tooltip(toggleBtn);
|
||||
}
|
||||
|
||||
function updateToggle(isActive) {
|
||||
toggleBtn.classList.toggle("is-active", isActive);
|
||||
toggleBtn.textContent = isActive ? "Tymczasowa ✔" : "Tymczasowa";
|
||||
toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
toggleBtn.setAttribute("title", isActive
|
||||
? "Lista tymczasowa będzie ważna przez 7 dni"
|
||||
: "Po zaznaczeniu lista będzie ważna tylko 7 dni");
|
||||
}
|
||||
|
||||
let active = toggleBtn.getAttribute("data-active") === "1";
|
||||
hiddenInput.value = active ? "1" : "0";
|
||||
updateToggle(active);
|
||||
|
||||
toggleBtn.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
active = !active;
|
||||
toggleBtn.setAttribute("data-active", active ? "1" : "0");
|
||||
hiddenInput.value = active ? "1" : "0";
|
||||
updateToggle(active);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var resetPasswordModal = document.getElementById('resetPasswordModal');
|
||||
resetPasswordModal.addEventListener('show.bs.modal', function (event) {
|
||||
var button = event.relatedTarget;
|
||||
9
shopping_app/static/lib/css/cropper.min.css
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/*!
|
||||
* Cropper.js v1.6.2
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2024-04-21T07:43:02.731Z
|
||||
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
||||
1
shopping_app/static/lib/css/sort_table.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.sortable thead th:not(.no-sort){cursor:pointer}.sortable thead th:not(.no-sort)::after,.sortable thead th:not(.no-sort)::before{transition:color .1s ease-in-out;font-size:1.2em;color:rgba(0,0,0,0)}.sortable thead th:not(.no-sort)::after{margin-left:3px;content:"▸"}.sortable thead th:not(.no-sort):hover::after{color:inherit}.sortable thead th:not(.no-sort)[aria-sort=descending]::after{color:inherit;content:"▾"}.sortable thead th:not(.no-sort)[aria-sort=ascending]::after{color:inherit;content:"▴"}.sortable thead th:not(.no-sort).indicator-left::after{content:""}.sortable thead th:not(.no-sort).indicator-left::before{margin-right:3px;content:"▸"}.sortable thead th:not(.no-sort).indicator-left:hover::before{color:inherit}.sortable thead th:not(.no-sort).indicator-left[aria-sort=descending]::before{color:inherit;content:"▾"}.sortable thead th:not(.no-sort).indicator-left[aria-sort=ascending]::before{color:inherit;content:"▴"}
|
||||
1
shopping_app/static/lib/css/tom-select.bootstrap5.min.css
vendored
Normal file
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 280 B |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
2
shopping_app/static/lib/js/Sortable.min.js
vendored
Normal file
10
shopping_app/static/lib/js/cropper.min.js
vendored
Normal file
4
shopping_app/static/lib/js/sort_table.min.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
document.addEventListener("click",function(d){try{var A=d.shiftKey||d.altKey,f=function k(a,l){return a.nodeName===l?a:k(a.parentNode,l)}(d.target,"TH"),v=f.parentNode,w=v.parentNode,g=w.parentNode;if("THEAD"===w.nodeName&&g.classList.contains("sortable")&&!f.classList.contains("no-sort")){var h=v.cells;for(d=0;d<h.length;d++)h[d]!==f&&h[d].removeAttribute("aria-sort");h="descending";("descending"===f.getAttribute("aria-sort")||g.classList.contains("asc")&&"ascending"!==f.getAttribute("aria-sort"))&&
|
||||
(h="ascending");f.setAttribute("aria-sort",h);g.dataset.timer&&clearTimeout(+g.dataset.timer);g.dataset.timer=setTimeout(function(){(function(a,l){function k(b){if(b){if(l&&b.dataset.sortAlt)return b.dataset.sortAlt;if(b.dataset.sort)return b.dataset.sort;if(b.textContent)return b.textContent}return""}a.dispatchEvent(new Event("sort-start",{bubbles:!0}));for(var p=a.tHead.querySelector("th[aria-sort]"),q=a.tHead.children[0],B="ascending"===p.getAttribute("aria-sort"),C=a.classList.contains("n-last"),
|
||||
y=function(b,m,c){var e=k(m.cells[c]),n=k(b.cells[c]);if(C){if(""===e&&""!==n)return-1;if(""===n&&""!==e)return 1}var x=+e-+n;e=isNaN(x)?e.localeCompare(n):x;return 0===e&&q.cells[c]&&q.cells[c].hasAttribute("data-sort-tbr")?y(b,m,+q.cells[c].dataset.sortTbr):B?-e:e},r=0;r<a.tBodies.length;r++){var t=a.tBodies[r],z=[].slice.call(t.rows,0);z.sort(function(b,m){var c;return y(b,m,+(null!==(c=p.dataset.sortCol)&&void 0!==c?c:p.cellIndex))});var u=t.cloneNode();u.append.apply(u,z);a.replaceChild(u,t)}a.dispatchEvent(new Event("sort-end",
|
||||
{bubbles:!0}))})(g,A)},1).toString()}}catch{}});
|
||||
443
shopping_app/static/lib/js/tom-select.complete.min.js
vendored
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Tom Select v2.4.3
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).TomSelect=t()}(this,(function(){"use strict"
|
||||
function e(e,t){e.split(/\s+/).forEach((e=>{t(e)}))}class t{constructor(){this._events={}}on(t,s){e(t,(e=>{const t=this._events[e]||[]
|
||||
t.push(s),this._events[e]=t}))}off(t,s){var i=arguments.length
|
||||
0!==i?e(t,(e=>{if(1===i)return void delete this._events[e]
|
||||
const t=this._events[e]
|
||||
void 0!==t&&(t.splice(t.indexOf(s),1),this._events[e]=t)})):this._events={}}trigger(t,...s){var i=this
|
||||
e(t,(e=>{const t=i._events[e]
|
||||
void 0!==t&&t.forEach((e=>{e.apply(i,s)}))}))}}const s=e=>(e=e.filter(Boolean)).length<2?e[0]||"":1==l(e)?"["+e.join("")+"]":"(?:"+e.join("|")+")",i=e=>{if(!o(e))return e.join("")
|
||||
let t="",s=0
|
||||
const i=()=>{s>1&&(t+="{"+s+"}")}
|
||||
return e.forEach(((n,o)=>{n!==e[o-1]?(i(),t+=n,s=1):s++})),i(),t},n=e=>{let t=Array.from(e)
|
||||
return s(t)},o=e=>new Set(e).size!==e.length,r=e=>(e+"").replace(/([\$\(\)\*\+\.\?\[\]\^\{\|\}\\])/gu,"\\$1"),l=e=>e.reduce(((e,t)=>Math.max(e,a(t))),0),a=e=>Array.from(e).length,c=e=>{if(1===e.length)return[[e]]
|
||||
let t=[]
|
||||
const s=e.substring(1)
|
||||
return c(s).forEach((function(s){let i=s.slice(0)
|
||||
i[0]=e.charAt(0)+i[0],t.push(i),i=s.slice(0),i.unshift(e.charAt(0)),t.push(i)})),t},d=[[0,65535]]
|
||||
let u,p
|
||||
const h={},g={"/":"⁄∕",0:"߀",a:"ⱥɐɑ",aa:"ꜳ",ae:"æǽǣ",ao:"ꜵ",au:"ꜷ",av:"ꜹꜻ",ay:"ꜽ",b:"ƀɓƃ",c:"ꜿƈȼↄ",d:"đɗɖᴅƌꮷԁɦ",e:"ɛǝᴇɇ",f:"ꝼƒ",g:"ǥɠꞡᵹꝿɢ",h:"ħⱨⱶɥ",i:"ɨı",j:"ɉȷ",k:"ƙⱪꝁꝃꝅꞣ",l:"łƚɫⱡꝉꝇꞁɭ",m:"ɱɯϻ",n:"ꞥƞɲꞑᴎлԉ",o:"øǿɔɵꝋꝍᴑ",oe:"œ",oi:"ƣ",oo:"ꝏ",ou:"ȣ",p:"ƥᵽꝑꝓꝕρ",q:"ꝗꝙɋ",r:"ɍɽꝛꞧꞃ",s:"ßȿꞩꞅʂ",t:"ŧƭʈⱦꞇ",th:"þ",tz:"ꜩ",u:"ʉ",v:"ʋꝟʌ",vy:"ꝡ",w:"ⱳ",y:"ƴɏỿ",z:"ƶȥɀⱬꝣ",hv:"ƕ"}
|
||||
for(let e in g){let t=g[e]||""
|
||||
for(let s=0;s<t.length;s++){let i=t.substring(s,s+1)
|
||||
h[i]=e}}const f=new RegExp(Object.keys(h).join("|")+"|[̀-ͯ·ʾʼ]","gu"),m=(e,t="NFKD")=>e.normalize(t),v=e=>Array.from(e).reduce(((e,t)=>e+y(t)),""),y=e=>(e=m(e).toLowerCase().replace(f,(e=>h[e]||"")),m(e,"NFC"))
|
||||
const O=e=>{const t={},s=(e,s)=>{const i=t[e]||new Set,o=new RegExp("^"+n(i)+"$","iu")
|
||||
s.match(o)||(i.add(r(s)),t[e]=i)}
|
||||
for(let t of function*(e){for(const[t,s]of e)for(let e=t;e<=s;e++){let t=String.fromCharCode(e),s=v(t)
|
||||
s!=t.toLowerCase()&&(s.length>3||0!=s.length&&(yield{folded:s,composed:t,code_point:e}))}}(e))s(t.folded,t.folded),s(t.folded,t.composed)
|
||||
return t},b=e=>{const t=O(e),i={}
|
||||
let o=[]
|
||||
for(let e in t){let s=t[e]
|
||||
s&&(i[e]=n(s)),e.length>1&&o.push(r(e))}o.sort(((e,t)=>t.length-e.length))
|
||||
const l=s(o)
|
||||
return p=new RegExp("^"+l,"u"),i},w=(e,t=1)=>(t=Math.max(t,e.length-1),s(c(e).map((e=>((e,t=1)=>{let s=0
|
||||
return e=e.map((e=>(u[e]&&(s+=e.length),u[e]||e))),s>=t?i(e):""})(e,t))))),_=(e,t=!0)=>{let n=e.length>1?1:0
|
||||
return s(e.map((e=>{let s=[]
|
||||
const o=t?e.length():e.length()-1
|
||||
for(let t=0;t<o;t++)s.push(w(e.substrs[t]||"",n))
|
||||
return i(s)})))},C=(e,t)=>{for(const s of t){if(s.start!=e.start||s.end!=e.end)continue
|
||||
if(s.substrs.join("")!==e.substrs.join(""))continue
|
||||
let t=e.parts
|
||||
const i=e=>{for(const s of t){if(s.start===e.start&&s.substr===e.substr)return!1
|
||||
if(1!=e.length&&1!=s.length){if(e.start<s.start&&e.end>s.start)return!0
|
||||
if(s.start<e.start&&s.end>e.start)return!0}}return!1}
|
||||
if(!(s.parts.filter(i).length>0))return!0}return!1}
|
||||
class S{parts
|
||||
substrs
|
||||
start
|
||||
end
|
||||
constructor(){this.parts=[],this.substrs=[],this.start=0,this.end=0}add(e){e&&(this.parts.push(e),this.substrs.push(e.substr),this.start=Math.min(e.start,this.start),this.end=Math.max(e.end,this.end))}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(e,t){let s=new S,i=JSON.parse(JSON.stringify(this.parts)),n=i.pop()
|
||||
for(const e of i)s.add(e)
|
||||
let o=t.substr.substring(0,e-n.start),r=o.length
|
||||
return s.add({start:n.start,end:n.start+r,length:r,substr:o}),s}}const I=e=>{void 0===u&&(u=b(d)),e=v(e)
|
||||
let t="",s=[new S]
|
||||
for(let i=0;i<e.length;i++){let n=e.substring(i).match(p)
|
||||
const o=e.substring(i,i+1),r=n?n[0]:null
|
||||
let l=[],a=new Set
|
||||
for(const e of s){const t=e.last()
|
||||
if(!t||1==t.length||t.end<=i)if(r){const t=r.length
|
||||
e.add({start:i,end:i+t,length:t,substr:r}),a.add("1")}else e.add({start:i,end:i+1,length:1,substr:o}),a.add("2")
|
||||
else if(r){let s=e.clone(i,t)
|
||||
const n=r.length
|
||||
s.add({start:i,end:i+n,length:n,substr:r}),l.push(s)}else a.add("3")}if(l.length>0){l=l.sort(((e,t)=>e.length()-t.length()))
|
||||
for(let e of l)C(e,s)||s.push(e)}else if(i>0&&1==a.size&&!a.has("3")){t+=_(s,!1)
|
||||
let e=new S
|
||||
const i=s[0]
|
||||
i&&e.add(i.last()),s=[e]}}return t+=_(s,!0),t},A=(e,t)=>{if(e)return e[t]},k=(e,t)=>{if(e){for(var s,i=t.split(".");(s=i.shift())&&(e=e[s]););return e}},x=(e,t,s)=>{var i,n
|
||||
return e?(e+="",null==t.regex||-1===(n=e.search(t.regex))?0:(i=t.string.length/e.length,0===n&&(i+=.5),i*s)):0},F=(e,t)=>{var s=e[t]
|
||||
if("function"==typeof s)return s
|
||||
s&&!Array.isArray(s)&&(e[t]=[s])},L=(e,t)=>{if(Array.isArray(e))e.forEach(t)
|
||||
else for(var s in e)e.hasOwnProperty(s)&&t(e[s],s)},E=(e,t)=>"number"==typeof e&&"number"==typeof t?e>t?1:e<t?-1:0:(e=v(e+"").toLowerCase())>(t=v(t+"").toLowerCase())?1:t>e?-1:0
|
||||
class T{items
|
||||
settings
|
||||
constructor(e,t){this.items=e,this.settings=t||{diacritics:!0}}tokenize(e,t,s){if(!e||!e.length)return[]
|
||||
const i=[],n=e.split(/\s+/)
|
||||
var o
|
||||
return s&&(o=new RegExp("^("+Object.keys(s).map(r).join("|")+"):(.*)$")),n.forEach((e=>{let s,n=null,l=null
|
||||
o&&(s=e.match(o))&&(n=s[1],e=s[2]),e.length>0&&(l=this.settings.diacritics?I(e)||null:r(e),l&&t&&(l="\\b"+l)),i.push({string:e,regex:l?new RegExp(l,"iu"):null,field:n})})),i}getScoreFunction(e,t){var s=this.prepareSearch(e,t)
|
||||
return this._getScoreFunction(s)}_getScoreFunction(e){const t=e.tokens,s=t.length
|
||||
if(!s)return function(){return 0}
|
||||
const i=e.options.fields,n=e.weights,o=i.length,r=e.getAttrFn
|
||||
if(!o)return function(){return 1}
|
||||
const l=1===o?function(e,t){const s=i[0].field
|
||||
return x(r(t,s),e,n[s]||1)}:function(e,t){var s=0
|
||||
if(e.field){const i=r(t,e.field)
|
||||
!e.regex&&i?s+=1/o:s+=x(i,e,1)}else L(n,((i,n)=>{s+=x(r(t,n),e,i)}))
|
||||
return s/o}
|
||||
return 1===s?function(e){return l(t[0],e)}:"and"===e.options.conjunction?function(e){var i,n=0
|
||||
for(let s of t){if((i=l(s,e))<=0)return 0
|
||||
n+=i}return n/s}:function(e){var i=0
|
||||
return L(t,(t=>{i+=l(t,e)})),i/s}}getSortFunction(e,t){var s=this.prepareSearch(e,t)
|
||||
return this._getSortFunction(s)}_getSortFunction(e){var t,s=[]
|
||||
const i=this,n=e.options,o=!e.query&&n.sort_empty?n.sort_empty:n.sort
|
||||
if("function"==typeof o)return o.bind(this)
|
||||
const r=function(t,s){return"$score"===t?s.score:e.getAttrFn(i.items[s.id],t)}
|
||||
if(o)for(let t of o)(e.query||"$score"!==t.field)&&s.push(t)
|
||||
if(e.query){t=!0
|
||||
for(let e of s)if("$score"===e.field){t=!1
|
||||
break}t&&s.unshift({field:"$score",direction:"desc"})}else s=s.filter((e=>"$score"!==e.field))
|
||||
return s.length?function(e,t){var i,n
|
||||
for(let o of s){if(n=o.field,i=("desc"===o.direction?-1:1)*E(r(n,e),r(n,t)))return i}return 0}:null}prepareSearch(e,t){const s={}
|
||||
var i=Object.assign({},t)
|
||||
if(F(i,"sort"),F(i,"sort_empty"),i.fields){F(i,"fields")
|
||||
const e=[]
|
||||
i.fields.forEach((t=>{"string"==typeof t&&(t={field:t,weight:1}),e.push(t),s[t.field]="weight"in t?t.weight:1})),i.fields=e}return{options:i,query:e.toLowerCase().trim(),tokens:this.tokenize(e,i.respect_word_boundaries,s),total:0,items:[],weights:s,getAttrFn:i.nesting?k:A}}search(e,t){var s,i,n=this
|
||||
i=this.prepareSearch(e,t),t=i.options,e=i.query
|
||||
const o=t.score||n._getScoreFunction(i)
|
||||
e.length?L(n.items,((e,n)=>{s=o(e),(!1===t.filter||s>0)&&i.items.push({score:s,id:n})})):L(n.items,((e,t)=>{i.items.push({score:1,id:t})}))
|
||||
const r=n._getSortFunction(i)
|
||||
return r&&i.items.sort(r),i.total=i.items.length,"number"==typeof t.limit&&(i.items=i.items.slice(0,t.limit)),i}}const P=e=>null==e?null:N(e),N=e=>"boolean"==typeof e?e?"1":"0":e+"",j=e=>(e+"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),$=(e,t)=>{var s
|
||||
return function(i,n){var o=this
|
||||
s&&(o.loading=Math.max(o.loading-1,0),clearTimeout(s)),s=setTimeout((function(){s=null,o.loadedSearches[i]=!0,e.call(o,i,n)}),t)}},V=(e,t,s)=>{var i,n=e.trigger,o={}
|
||||
for(i of(e.trigger=function(){var s=arguments[0]
|
||||
if(-1===t.indexOf(s))return n.apply(e,arguments)
|
||||
o[s]=arguments},s.apply(e,[]),e.trigger=n,t))i in o&&n.apply(e,o[i])},q=(e,t=!1)=>{e&&(e.preventDefault(),t&&e.stopPropagation())},D=(e,t,s,i)=>{e.addEventListener(t,s,i)},H=(e,t)=>!!t&&(!!t[e]&&1===(t.altKey?1:0)+(t.ctrlKey?1:0)+(t.shiftKey?1:0)+(t.metaKey?1:0)),R=(e,t)=>{const s=e.getAttribute("id")
|
||||
return s||(e.setAttribute("id",t),t)},M=e=>e.replace(/[\\"']/g,"\\$&"),z=(e,t)=>{t&&e.append(t)},B=(e,t)=>{if(Array.isArray(e))e.forEach(t)
|
||||
else for(var s in e)e.hasOwnProperty(s)&&t(e[s],s)},K=e=>{if(e.jquery)return e[0]
|
||||
if(e instanceof HTMLElement)return e
|
||||
if(Q(e)){var t=document.createElement("template")
|
||||
return t.innerHTML=e.trim(),t.content.firstChild}return document.querySelector(e)},Q=e=>"string"==typeof e&&e.indexOf("<")>-1,G=(e,t)=>{var s=document.createEvent("HTMLEvents")
|
||||
s.initEvent(t,!0,!1),e.dispatchEvent(s)},U=(e,t)=>{Object.assign(e.style,t)},J=(e,...t)=>{var s=X(t);(e=Y(e)).map((e=>{s.map((t=>{e.classList.add(t)}))}))},W=(e,...t)=>{var s=X(t);(e=Y(e)).map((e=>{s.map((t=>{e.classList.remove(t)}))}))},X=e=>{var t=[]
|
||||
return B(e,(e=>{"string"==typeof e&&(e=e.trim().split(/[\t\n\f\r\s]/)),Array.isArray(e)&&(t=t.concat(e))})),t.filter(Boolean)},Y=e=>(Array.isArray(e)||(e=[e]),e),Z=(e,t,s)=>{if(!s||s.contains(e))for(;e&&e.matches;){if(e.matches(t))return e
|
||||
e=e.parentNode}},ee=(e,t=0)=>t>0?e[e.length-1]:e[0],te=(e,t)=>{if(!e)return-1
|
||||
t=t||e.nodeName
|
||||
for(var s=0;e=e.previousElementSibling;)e.matches(t)&&s++
|
||||
return s},se=(e,t)=>{B(t,((t,s)=>{null==t?e.removeAttribute(s):e.setAttribute(s,""+t)}))},ie=(e,t)=>{e.parentNode&&e.parentNode.replaceChild(t,e)},ne=(e,t)=>{if(null===t)return
|
||||
if("string"==typeof t){if(!t.length)return
|
||||
t=new RegExp(t,"i")}const s=e=>3===e.nodeType?(e=>{var s=e.data.match(t)
|
||||
if(s&&e.data.length>0){var i=document.createElement("span")
|
||||
i.className="highlight"
|
||||
var n=e.splitText(s.index)
|
||||
n.splitText(s[0].length)
|
||||
var o=n.cloneNode(!0)
|
||||
return i.appendChild(o),ie(n,i),1}return 0})(e):((e=>{1!==e.nodeType||!e.childNodes||/(script|style)/i.test(e.tagName)||"highlight"===e.className&&"SPAN"===e.tagName||Array.from(e.childNodes).forEach((e=>{s(e)}))})(e),0)
|
||||
s(e)},oe="undefined"!=typeof navigator&&/Mac/.test(navigator.userAgent)?"metaKey":"ctrlKey"
|
||||
var re={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:null,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,shouldOpen:null,maxOptions:50,maxItems:null,hideSelected:null,duplicates:!1,addPrecedence:!1,selectOnTab:!1,preload:null,allowEmptyOption:!1,refreshThrottle:300,loadThrottle:300,loadingClass:"loading",dataAttr:null,optgroupField:"optgroup",valueField:"value",labelField:"text",disabledField:"disabled",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"ts-wrapper",controlClass:"ts-control",dropdownClass:"ts-dropdown",dropdownContentClass:"ts-dropdown-content",itemClass:"item",optionClass:"option",dropdownParent:null,controlInput:'<input type="text" autocomplete="off" size="1" />',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(e){return e.length>0},render:{}}
|
||||
function le(e,t){var s=Object.assign({},re,t),i=s.dataAttr,n=s.labelField,o=s.valueField,r=s.disabledField,l=s.optgroupField,a=s.optgroupLabelField,c=s.optgroupValueField,d=e.tagName.toLowerCase(),u=e.getAttribute("placeholder")||e.getAttribute("data-placeholder")
|
||||
if(!u&&!s.allowEmptyOption){let t=e.querySelector('option[value=""]')
|
||||
t&&(u=t.textContent)}var p={placeholder:u,options:[],optgroups:[],items:[],maxItems:null}
|
||||
return"select"===d?(()=>{var t,d=p.options,u={},h=1
|
||||
let g=0
|
||||
var f=e=>{var t=Object.assign({},e.dataset),s=i&&t[i]
|
||||
return"string"==typeof s&&s.length&&(t=Object.assign(t,JSON.parse(s))),t},m=(e,t)=>{var i=P(e.value)
|
||||
if(null!=i&&(i||s.allowEmptyOption)){if(u.hasOwnProperty(i)){if(t){var a=u[i][l]
|
||||
a?Array.isArray(a)?a.push(t):u[i][l]=[a,t]:u[i][l]=t}}else{var c=f(e)
|
||||
c[n]=c[n]||e.textContent,c[o]=c[o]||i,c[r]=c[r]||e.disabled,c[l]=c[l]||t,c.$option=e,c.$order=c.$order||++g,u[i]=c,d.push(c)}e.selected&&p.items.push(i)}}
|
||||
p.maxItems=e.hasAttribute("multiple")?null:1,B(e.children,(e=>{var s,i,n
|
||||
"optgroup"===(t=e.tagName.toLowerCase())?((n=f(s=e))[a]=n[a]||s.getAttribute("label")||"",n[c]=n[c]||h++,n[r]=n[r]||s.disabled,n.$order=n.$order||++g,p.optgroups.push(n),i=n[c],B(s.children,(e=>{m(e,i)}))):"option"===t&&m(e)}))})():(()=>{const t=e.getAttribute(i)
|
||||
if(t)p.options=JSON.parse(t),B(p.options,(e=>{p.items.push(e[o])}))
|
||||
else{var r=e.value.trim()||""
|
||||
if(!s.allowEmptyOption&&!r.length)return
|
||||
const t=r.split(s.delimiter)
|
||||
B(t,(e=>{const t={}
|
||||
t[n]=e,t[o]=e,p.options.push(t)})),p.items=t}})(),Object.assign({},re,p,t)}var ae=0
|
||||
class ce extends(function(e){return e.plugins={},class extends e{constructor(...e){super(...e),this.plugins={names:[],settings:{},requested:{},loaded:{}}}static define(t,s){e.plugins[t]={name:t,fn:s}}initializePlugins(e){var t,s
|
||||
const i=this,n=[]
|
||||
if(Array.isArray(e))e.forEach((e=>{"string"==typeof e?n.push(e):(i.plugins.settings[e.name]=e.options,n.push(e.name))}))
|
||||
else if(e)for(t in e)e.hasOwnProperty(t)&&(i.plugins.settings[t]=e[t],n.push(t))
|
||||
for(;s=n.shift();)i.require(s)}loadPlugin(t){var s=this,i=s.plugins,n=e.plugins[t]
|
||||
if(!e.plugins.hasOwnProperty(t))throw new Error('Unable to find "'+t+'" plugin')
|
||||
i.requested[t]=!0,i.loaded[t]=n.fn.apply(s,[s.plugins.settings[t]||{}]),i.names.push(t)}require(e){var t=this,s=t.plugins
|
||||
if(!t.plugins.loaded.hasOwnProperty(e)){if(s.requested[e])throw new Error('Plugin has circular dependency ("'+e+'")')
|
||||
t.loadPlugin(e)}return s.loaded[e]}}}(t)){constructor(e,t){var s
|
||||
super(),this.order=0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,ae++
|
||||
var i=K(e)
|
||||
if(i.tomselect)throw new Error("Tom Select already initialized on this element")
|
||||
i.tomselect=this,s=(window.getComputedStyle&&window.getComputedStyle(i,null)).getPropertyValue("direction")
|
||||
const n=le(i,t)
|
||||
this.settings=n,this.input=i,this.tabIndex=i.tabIndex||0,this.is_select_tag="select"===i.tagName.toLowerCase(),this.rtl=/rtl/i.test(s),this.inputId=R(i,"tomselect-"+ae),this.isRequired=i.required,this.sifter=new T(this.options,{diacritics:n.diacritics}),n.mode=n.mode||(1===n.maxItems?"single":"multi"),"boolean"!=typeof n.hideSelected&&(n.hideSelected="multi"===n.mode),"boolean"!=typeof n.hidePlaceholder&&(n.hidePlaceholder="multi"!==n.mode)
|
||||
var o=n.createFilter
|
||||
"function"!=typeof o&&("string"==typeof o&&(o=new RegExp(o)),o instanceof RegExp?n.createFilter=e=>o.test(e):n.createFilter=e=>this.settings.duplicates||!this.options[e]),this.initializePlugins(n.plugins),this.setupCallbacks(),this.setupTemplates()
|
||||
const r=K("<div>"),l=K("<div>"),a=this._render("dropdown"),c=K('<div role="listbox" tabindex="-1">'),d=this.input.getAttribute("class")||"",u=n.mode
|
||||
var p
|
||||
if(J(r,n.wrapperClass,d,u),J(l,n.controlClass),z(r,l),J(a,n.dropdownClass,u),n.copyClassesToDropdown&&J(a,d),J(c,n.dropdownContentClass),z(a,c),K(n.dropdownParent||r).appendChild(a),Q(n.controlInput)){p=K(n.controlInput)
|
||||
B(["autocorrect","autocapitalize","autocomplete","spellcheck"],(e=>{i.getAttribute(e)&&se(p,{[e]:i.getAttribute(e)})})),p.tabIndex=-1,l.appendChild(p),this.focus_node=p}else n.controlInput?(p=K(n.controlInput),this.focus_node=p):(p=K("<input/>"),this.focus_node=l)
|
||||
this.wrapper=r,this.dropdown=a,this.dropdown_content=c,this.control=l,this.control_input=p,this.setup()}setup(){const e=this,t=e.settings,s=e.control_input,i=e.dropdown,n=e.dropdown_content,o=e.wrapper,l=e.control,a=e.input,c=e.focus_node,d={passive:!0},u=e.inputId+"-ts-dropdown"
|
||||
se(n,{id:u}),se(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":u})
|
||||
const p=R(c,e.inputId+"-ts-control"),h="label[for='"+(e=>e.replace(/['"\\]/g,"\\$&"))(e.inputId)+"']",g=document.querySelector(h),f=e.focus.bind(e)
|
||||
if(g){D(g,"click",f),se(g,{for:p})
|
||||
const t=R(g,e.inputId+"-ts-label")
|
||||
se(c,{"aria-labelledby":t}),se(n,{"aria-labelledby":t})}if(o.style.width=a.style.width,e.plugins.names.length){const t="plugin-"+e.plugins.names.join(" plugin-")
|
||||
J([o,i],t)}(null===t.maxItems||t.maxItems>1)&&e.is_select_tag&&se(a,{multiple:"multiple"}),t.placeholder&&se(s,{placeholder:t.placeholder}),!t.splitOn&&t.delimiter&&(t.splitOn=new RegExp("\\s*"+r(t.delimiter)+"+\\s*")),t.load&&t.loadThrottle&&(t.load=$(t.load,t.loadThrottle)),D(i,"mousemove",(()=>{e.ignoreHover=!1})),D(i,"mouseenter",(t=>{var s=Z(t.target,"[data-selectable]",i)
|
||||
s&&e.onOptionHover(t,s)}),{capture:!0}),D(i,"click",(t=>{const s=Z(t.target,"[data-selectable]")
|
||||
s&&(e.onOptionSelect(t,s),q(t,!0))})),D(l,"click",(t=>{var i=Z(t.target,"[data-ts-item]",l)
|
||||
i&&e.onItemSelect(t,i)?q(t,!0):""==s.value&&(e.onClick(),q(t,!0))})),D(c,"keydown",(t=>e.onKeyDown(t))),D(s,"keypress",(t=>e.onKeyPress(t))),D(s,"input",(t=>e.onInput(t))),D(c,"blur",(t=>e.onBlur(t))),D(c,"focus",(t=>e.onFocus(t))),D(s,"paste",(t=>e.onPaste(t)))
|
||||
const m=t=>{const n=t.composedPath()[0]
|
||||
if(!o.contains(n)&&!i.contains(n))return e.isFocused&&e.blur(),void e.inputState()
|
||||
n==s&&e.isOpen?t.stopPropagation():q(t,!0)},v=()=>{e.isOpen&&e.positionDropdown()}
|
||||
D(document,"mousedown",m),D(window,"scroll",v,d),D(window,"resize",v,d),this._destroy=()=>{document.removeEventListener("mousedown",m),window.removeEventListener("scroll",v),window.removeEventListener("resize",v),g&&g.removeEventListener("click",f)},this.revertSettings={innerHTML:a.innerHTML,tabIndex:a.tabIndex},a.tabIndex=-1,a.insertAdjacentElement("afterend",e.wrapper),e.sync(!1),t.items=[],delete t.optgroups,delete t.options,D(a,"invalid",(()=>{e.isValid&&(e.isValid=!1,e.isInvalid=!0,e.refreshState())})),e.updateOriginalInput(),e.refreshItems(),e.close(!1),e.inputState(),e.isSetup=!0,a.disabled?e.disable():a.readOnly?e.setReadOnly(!0):e.enable(),e.on("change",this.onChange),J(a,"tomselected","ts-hidden-accessible"),e.trigger("initialize"),!0===t.preload&&e.preload()}setupOptions(e=[],t=[]){this.addOptions(e),B(t,(e=>{this.registerOptionGroup(e)}))}setupTemplates(){var e=this,t=e.settings.labelField,s=e.settings.optgroupLabelField,i={optgroup:e=>{let t=document.createElement("div")
|
||||
return t.className="optgroup",t.appendChild(e.options),t},optgroup_header:(e,t)=>'<div class="optgroup-header">'+t(e[s])+"</div>",option:(e,s)=>"<div>"+s(e[t])+"</div>",item:(e,s)=>"<div>"+s(e[t])+"</div>",option_create:(e,t)=>'<div class="create">Add <strong>'+t(e.input)+"</strong>…</div>",no_results:()=>'<div class="no-results">No results found</div>',loading:()=>'<div class="spinner"></div>',not_loading:()=>{},dropdown:()=>"<div></div>"}
|
||||
e.settings.render=Object.assign({},i,e.settings.render)}setupCallbacks(){var e,t,s={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"}
|
||||
for(e in s)(t=this.settings[s[e]])&&this.on(e,t)}sync(e=!0){const t=this,s=e?le(t.input,{delimiter:t.settings.delimiter}):t.settings
|
||||
t.setupOptions(s.options,s.optgroups),t.setValue(s.items||[],!0),t.lastQuery=null}onClick(){var e=this
|
||||
if(e.activeItems.length>0)return e.clearActiveItems(),void e.focus()
|
||||
e.isFocused&&e.isOpen?e.blur():e.focus()}onMouseDown(){}onChange(){G(this.input,"input"),G(this.input,"change")}onPaste(e){var t=this
|
||||
t.isInputHidden||t.isLocked?q(e):t.settings.splitOn&&setTimeout((()=>{var e=t.inputValue()
|
||||
if(e.match(t.settings.splitOn)){var s=e.trim().split(t.settings.splitOn)
|
||||
B(s,(e=>{P(e)&&(this.options[e]?t.addItem(e):t.createItem(e))}))}}),0)}onKeyPress(e){var t=this
|
||||
if(!t.isLocked){var s=String.fromCharCode(e.keyCode||e.which)
|
||||
return t.settings.create&&"multi"===t.settings.mode&&s===t.settings.delimiter?(t.createItem(),void q(e)):void 0}q(e)}onKeyDown(e){var t=this
|
||||
if(t.ignoreHover=!0,t.isLocked)9!==e.keyCode&&q(e)
|
||||
else{switch(e.keyCode){case 65:if(H(oe,e)&&""==t.control_input.value)return q(e),void t.selectAll()
|
||||
break
|
||||
case 27:return t.isOpen&&(q(e,!0),t.close()),void t.clearActiveItems()
|
||||
case 40:if(!t.isOpen&&t.hasOptions)t.open()
|
||||
else if(t.activeOption){let e=t.getAdjacent(t.activeOption,1)
|
||||
e&&t.setActiveOption(e)}return void q(e)
|
||||
case 38:if(t.activeOption){let e=t.getAdjacent(t.activeOption,-1)
|
||||
e&&t.setActiveOption(e)}return void q(e)
|
||||
case 13:return void(t.canSelect(t.activeOption)?(t.onOptionSelect(e,t.activeOption),q(e)):(t.settings.create&&t.createItem()||document.activeElement==t.control_input&&t.isOpen)&&q(e))
|
||||
case 37:return void t.advanceSelection(-1,e)
|
||||
case 39:return void t.advanceSelection(1,e)
|
||||
case 9:return void(t.settings.selectOnTab&&(t.canSelect(t.activeOption)&&(t.onOptionSelect(e,t.activeOption),q(e)),t.settings.create&&t.createItem()&&q(e)))
|
||||
case 8:case 46:return void t.deleteSelection(e)}t.isInputHidden&&!H(oe,e)&&q(e)}}onInput(e){if(this.isLocked)return
|
||||
const t=this.inputValue()
|
||||
this.lastValue!==t&&(this.lastValue=t,""!=t?(this.refreshTimeout&&window.clearTimeout(this.refreshTimeout),this.refreshTimeout=((e,t)=>t>0?window.setTimeout(e,t):(e.call(null),null))((()=>{this.refreshTimeout=null,this._onInput()}),this.settings.refreshThrottle)):this._onInput())}_onInput(){const e=this.lastValue
|
||||
this.settings.shouldLoad.call(this,e)&&this.load(e),this.refreshOptions(),this.trigger("type",e)}onOptionHover(e,t){this.ignoreHover||this.setActiveOption(t,!1)}onFocus(e){var t=this,s=t.isFocused
|
||||
if(t.isDisabled||t.isReadOnly)return t.blur(),void q(e)
|
||||
t.ignoreFocus||(t.isFocused=!0,"focus"===t.settings.preload&&t.preload(),s||t.trigger("focus"),t.activeItems.length||(t.inputState(),t.refreshOptions(!!t.settings.openOnFocus)),t.refreshState())}onBlur(e){if(!1!==document.hasFocus()){var t=this
|
||||
if(t.isFocused){t.isFocused=!1,t.ignoreFocus=!1
|
||||
var s=()=>{t.close(),t.setActiveItem(),t.setCaret(t.items.length),t.trigger("blur")}
|
||||
t.settings.create&&t.settings.createOnBlur?t.createItem(null,s):s()}}}onOptionSelect(e,t){var s,i=this
|
||||
t.parentElement&&t.parentElement.matches("[data-disabled]")||(t.classList.contains("create")?i.createItem(null,(()=>{i.settings.closeAfterSelect&&i.close()})):void 0!==(s=t.dataset.value)&&(i.lastQuery=null,i.addItem(s),i.settings.closeAfterSelect&&i.close(),!i.settings.hideSelected&&e.type&&/click/.test(e.type)&&i.setActiveOption(t)))}canSelect(e){return!!(this.isOpen&&e&&this.dropdown_content.contains(e))}onItemSelect(e,t){var s=this
|
||||
return!s.isLocked&&"multi"===s.settings.mode&&(q(e),s.setActiveItem(t,e),!0)}canLoad(e){return!!this.settings.load&&!this.loadedSearches.hasOwnProperty(e)}load(e){const t=this
|
||||
if(!t.canLoad(e))return
|
||||
J(t.wrapper,t.settings.loadingClass),t.loading++
|
||||
const s=t.loadCallback.bind(t)
|
||||
t.settings.load.call(t,e,s)}loadCallback(e,t){const s=this
|
||||
s.loading=Math.max(s.loading-1,0),s.lastQuery=null,s.clearActiveOption(),s.setupOptions(e,t),s.refreshOptions(s.isFocused&&!s.isInputHidden),s.loading||W(s.wrapper,s.settings.loadingClass),s.trigger("load",e,t)}preload(){var e=this.wrapper.classList
|
||||
e.contains("preloaded")||(e.add("preloaded"),this.load(""))}setTextboxValue(e=""){var t=this.control_input
|
||||
t.value!==e&&(t.value=e,G(t,"update"),this.lastValue=e)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(e,t){V(this,t?[]:["change"],(()=>{this.clear(t),this.addItems(e,t)}))}setMaxItems(e){0===e&&(e=null),this.settings.maxItems=e,this.refreshState()}setActiveItem(e,t){var s,i,n,o,r,l,a=this
|
||||
if("single"!==a.settings.mode){if(!e)return a.clearActiveItems(),void(a.isFocused&&a.inputState())
|
||||
if("click"===(s=t&&t.type.toLowerCase())&&H("shiftKey",t)&&a.activeItems.length){for(l=a.getLastActive(),(n=Array.prototype.indexOf.call(a.control.children,l))>(o=Array.prototype.indexOf.call(a.control.children,e))&&(r=n,n=o,o=r),i=n;i<=o;i++)e=a.control.children[i],-1===a.activeItems.indexOf(e)&&a.setActiveItemClass(e)
|
||||
q(t)}else"click"===s&&H(oe,t)||"keydown"===s&&H("shiftKey",t)?e.classList.contains("active")?a.removeActiveItem(e):a.setActiveItemClass(e):(a.clearActiveItems(),a.setActiveItemClass(e))
|
||||
a.inputState(),a.isFocused||a.focus()}}setActiveItemClass(e){const t=this,s=t.control.querySelector(".last-active")
|
||||
s&&W(s,"last-active"),J(e,"active last-active"),t.trigger("item_select",e),-1==t.activeItems.indexOf(e)&&t.activeItems.push(e)}removeActiveItem(e){var t=this.activeItems.indexOf(e)
|
||||
this.activeItems.splice(t,1),W(e,"active")}clearActiveItems(){W(this.activeItems,"active"),this.activeItems=[]}setActiveOption(e,t=!0){e!==this.activeOption&&(this.clearActiveOption(),e&&(this.activeOption=e,se(this.focus_node,{"aria-activedescendant":e.getAttribute("id")}),se(e,{"aria-selected":"true"}),J(e,"active"),t&&this.scrollToOption(e)))}scrollToOption(e,t){if(!e)return
|
||||
const s=this.dropdown_content,i=s.clientHeight,n=s.scrollTop||0,o=e.offsetHeight,r=e.getBoundingClientRect().top-s.getBoundingClientRect().top+n
|
||||
r+o>i+n?this.scroll(r-i+o,t):r<n&&this.scroll(r,t)}scroll(e,t){const s=this.dropdown_content
|
||||
t&&(s.style.scrollBehavior=t),s.scrollTop=e,s.style.scrollBehavior=""}clearActiveOption(){this.activeOption&&(W(this.activeOption,"active"),se(this.activeOption,{"aria-selected":null})),this.activeOption=null,se(this.focus_node,{"aria-activedescendant":null})}selectAll(){const e=this
|
||||
if("single"===e.settings.mode)return
|
||||
const t=e.controlChildren()
|
||||
t.length&&(e.inputState(),e.close(),e.activeItems=t,B(t,(t=>{e.setActiveItemClass(t)})))}inputState(){var e=this
|
||||
e.control.contains(e.control_input)&&(se(e.control_input,{placeholder:e.settings.placeholder}),e.activeItems.length>0||!e.isFocused&&e.settings.hidePlaceholder&&e.items.length>0?(e.setTextboxValue(),e.isInputHidden=!0):(e.settings.hidePlaceholder&&e.items.length>0&&se(e.control_input,{placeholder:""}),e.isInputHidden=!1),e.wrapper.classList.toggle("input-hidden",e.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var e=this
|
||||
e.isDisabled||e.isReadOnly||(e.ignoreFocus=!0,e.control_input.offsetWidth?e.control_input.focus():e.focus_node.focus(),setTimeout((()=>{e.ignoreFocus=!1,e.onFocus()}),0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(e){return this.sifter.getScoreFunction(e,this.getSearchOptions())}getSearchOptions(){var e=this.settings,t=e.sortField
|
||||
return"string"==typeof e.sortField&&(t=[{field:e.sortField}]),{fields:e.searchField,conjunction:e.searchConjunction,sort:t,nesting:e.nesting}}search(e){var t,s,i=this,n=this.getSearchOptions()
|
||||
if(i.settings.score&&"function"!=typeof(s=i.settings.score.call(i,e)))throw new Error('Tom Select "score" setting must be a function that returns a function')
|
||||
return e!==i.lastQuery?(i.lastQuery=e,t=i.sifter.search(e,Object.assign(n,{score:s})),i.currentResults=t):t=Object.assign({},i.currentResults),i.settings.hideSelected&&(t.items=t.items.filter((e=>{let t=P(e.id)
|
||||
return!(t&&-1!==i.items.indexOf(t))}))),t}refreshOptions(e=!0){var t,s,i,n,o,r,l,a,c,d
|
||||
const u={},p=[]
|
||||
var h=this,g=h.inputValue()
|
||||
const f=g===h.lastQuery||""==g&&null==h.lastQuery
|
||||
var m=h.search(g),v=null,y=h.settings.shouldOpen||!1,O=h.dropdown_content
|
||||
f&&(v=h.activeOption)&&(c=v.closest("[data-group]")),n=m.items.length,"number"==typeof h.settings.maxOptions&&(n=Math.min(n,h.settings.maxOptions)),n>0&&(y=!0)
|
||||
const b=(e,t)=>{let s=u[e]
|
||||
if(void 0!==s){let e=p[s]
|
||||
if(void 0!==e)return[s,e.fragment]}let i=document.createDocumentFragment()
|
||||
return s=p.length,p.push({fragment:i,order:t,optgroup:e}),[s,i]}
|
||||
for(t=0;t<n;t++){let e=m.items[t]
|
||||
if(!e)continue
|
||||
let n=e.id,l=h.options[n]
|
||||
if(void 0===l)continue
|
||||
let a=N(n),d=h.getOption(a,!0)
|
||||
for(h.settings.hideSelected||d.classList.toggle("selected",h.items.includes(a)),o=l[h.settings.optgroupField]||"",s=0,i=(r=Array.isArray(o)?o:[o])&&r.length;s<i;s++){o=r[s]
|
||||
let e=l.$order,t=h.optgroups[o]
|
||||
void 0===t?o="":e=t.$order
|
||||
const[i,a]=b(o,e)
|
||||
s>0&&(d=d.cloneNode(!0),se(d,{id:l.$id+"-clone-"+s,"aria-selected":null}),d.classList.add("ts-cloned"),W(d,"active"),h.activeOption&&h.activeOption.dataset.value==n&&c&&c.dataset.group===o.toString()&&(v=d)),a.appendChild(d),""!=o&&(u[o]=i)}}var w
|
||||
h.settings.lockOptgroupOrder&&p.sort(((e,t)=>e.order-t.order)),l=document.createDocumentFragment(),B(p,(e=>{let t=e.fragment,s=e.optgroup
|
||||
if(!t||!t.children.length)return
|
||||
let i=h.optgroups[s]
|
||||
if(void 0!==i){let e=document.createDocumentFragment(),s=h.render("optgroup_header",i)
|
||||
z(e,s),z(e,t)
|
||||
let n=h.render("optgroup",{group:i,options:e})
|
||||
z(l,n)}else z(l,t)})),O.innerHTML="",z(O,l),h.settings.highlight&&(w=O.querySelectorAll("span.highlight"),Array.prototype.forEach.call(w,(function(e){var t=e.parentNode
|
||||
t.replaceChild(e.firstChild,e),t.normalize()})),m.query.length&&m.tokens.length&&B(m.tokens,(e=>{ne(O,e.regex)})))
|
||||
var _=e=>{let t=h.render(e,{input:g})
|
||||
return t&&(y=!0,O.insertBefore(t,O.firstChild)),t}
|
||||
if(h.loading?_("loading"):h.settings.shouldLoad.call(h,g)?0===m.items.length&&_("no_results"):_("not_loading"),(a=h.canCreate(g))&&(d=_("option_create")),h.hasOptions=m.items.length>0||a,y){if(m.items.length>0){if(v||"single"!==h.settings.mode||null==h.items[0]||(v=h.getOption(h.items[0])),!O.contains(v)){let e=0
|
||||
d&&!h.settings.addPrecedence&&(e=1),v=h.selectable()[e]}}else d&&(v=d)
|
||||
e&&!h.isOpen&&(h.open(),h.scrollToOption(v,"auto")),h.setActiveOption(v)}else h.clearActiveOption(),e&&h.isOpen&&h.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(e,t=!1){const s=this
|
||||
if(Array.isArray(e))return s.addOptions(e,t),!1
|
||||
const i=P(e[s.settings.valueField])
|
||||
return null!==i&&!s.options.hasOwnProperty(i)&&(e.$order=e.$order||++s.order,e.$id=s.inputId+"-opt-"+e.$order,s.options[i]=e,s.lastQuery=null,t&&(s.userOptions[i]=t,s.trigger("option_add",i,e)),i)}addOptions(e,t=!1){B(e,(e=>{this.addOption(e,t)}))}registerOption(e){return this.addOption(e)}registerOptionGroup(e){var t=P(e[this.settings.optgroupValueField])
|
||||
return null!==t&&(e.$order=e.$order||++this.order,this.optgroups[t]=e,t)}addOptionGroup(e,t){var s
|
||||
t[this.settings.optgroupValueField]=e,(s=this.registerOptionGroup(t))&&this.trigger("optgroup_add",s,t)}removeOptionGroup(e){this.optgroups.hasOwnProperty(e)&&(delete this.optgroups[e],this.clearCache(),this.trigger("optgroup_remove",e))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(e,t){const s=this
|
||||
var i,n
|
||||
const o=P(e),r=P(t[s.settings.valueField])
|
||||
if(null===o)return
|
||||
const l=s.options[o]
|
||||
if(null==l)return
|
||||
if("string"!=typeof r)throw new Error("Value must be set in option data")
|
||||
const a=s.getOption(o),c=s.getItem(o)
|
||||
if(t.$order=t.$order||l.$order,delete s.options[o],s.uncacheValue(r),s.options[r]=t,a){if(s.dropdown_content.contains(a)){const e=s._render("option",t)
|
||||
ie(a,e),s.activeOption===a&&s.setActiveOption(e)}a.remove()}c&&(-1!==(n=s.items.indexOf(o))&&s.items.splice(n,1,r),i=s._render("item",t),c.classList.contains("active")&&J(i,"active"),ie(c,i)),s.lastQuery=null}removeOption(e,t){const s=this
|
||||
e=N(e),s.uncacheValue(e),delete s.userOptions[e],delete s.options[e],s.lastQuery=null,s.trigger("option_remove",e),s.removeItem(e,t)}clearOptions(e){const t=(e||this.clearFilter).bind(this)
|
||||
this.loadedSearches={},this.userOptions={},this.clearCache()
|
||||
const s={}
|
||||
B(this.options,((e,i)=>{t(e,i)&&(s[i]=e)})),this.options=this.sifter.items=s,this.lastQuery=null,this.trigger("option_clear")}clearFilter(e,t){return this.items.indexOf(t)>=0}getOption(e,t=!1){const s=P(e)
|
||||
if(null===s)return null
|
||||
const i=this.options[s]
|
||||
if(null!=i){if(i.$div)return i.$div
|
||||
if(t)return this._render("option",i)}return null}getAdjacent(e,t,s="option"){var i
|
||||
if(!e)return null
|
||||
i="item"==s?this.controlChildren():this.dropdown_content.querySelectorAll("[data-selectable]")
|
||||
for(let s=0;s<i.length;s++)if(i[s]==e)return t>0?i[s+1]:i[s-1]
|
||||
return null}getItem(e){if("object"==typeof e)return e
|
||||
var t=P(e)
|
||||
return null!==t?this.control.querySelector(`[data-value="${M(t)}"]`):null}addItems(e,t){var s=this,i=Array.isArray(e)?e:[e]
|
||||
const n=(i=i.filter((e=>-1===s.items.indexOf(e))))[i.length-1]
|
||||
i.forEach((e=>{s.isPending=e!==n,s.addItem(e,t)}))}addItem(e,t){V(this,t?[]:["change","dropdown_close"],(()=>{var s,i
|
||||
const n=this,o=n.settings.mode,r=P(e)
|
||||
if((!r||-1===n.items.indexOf(r)||("single"===o&&n.close(),"single"!==o&&n.settings.duplicates))&&null!==r&&n.options.hasOwnProperty(r)&&("single"===o&&n.clear(t),"multi"!==o||!n.isFull())){if(s=n._render("item",n.options[r]),n.control.contains(s)&&(s=s.cloneNode(!0)),i=n.isFull(),n.items.splice(n.caretPos,0,r),n.insertAtCaret(s),n.isSetup){if(!n.isPending&&n.settings.hideSelected){let e=n.getOption(r),t=n.getAdjacent(e,1)
|
||||
t&&n.setActiveOption(t)}n.isPending||n.settings.closeAfterSelect||n.refreshOptions(n.isFocused&&"single"!==o),0!=n.settings.closeAfterSelect&&n.isFull()?n.close():n.isPending||n.positionDropdown(),n.trigger("item_add",r,s),n.isPending||n.updateOriginalInput({silent:t})}(!n.isPending||!i&&n.isFull())&&(n.inputState(),n.refreshState())}}))}removeItem(e=null,t){const s=this
|
||||
if(!(e=s.getItem(e)))return
|
||||
var i,n
|
||||
const o=e.dataset.value
|
||||
i=te(e),e.remove(),e.classList.contains("active")&&(n=s.activeItems.indexOf(e),s.activeItems.splice(n,1),W(e,"active")),s.items.splice(i,1),s.lastQuery=null,!s.settings.persist&&s.userOptions.hasOwnProperty(o)&&s.removeOption(o,t),i<s.caretPos&&s.setCaret(s.caretPos-1),s.updateOriginalInput({silent:t}),s.refreshState(),s.positionDropdown(),s.trigger("item_remove",o,e)}createItem(e=null,t=()=>{}){3===arguments.length&&(t=arguments[2]),"function"!=typeof t&&(t=()=>{})
|
||||
var s,i=this,n=i.caretPos
|
||||
if(e=e||i.inputValue(),!i.canCreate(e))return t(),!1
|
||||
i.lock()
|
||||
var o=!1,r=e=>{if(i.unlock(),!e||"object"!=typeof e)return t()
|
||||
var s=P(e[i.settings.valueField])
|
||||
if("string"!=typeof s)return t()
|
||||
i.setTextboxValue(),i.addOption(e,!0),i.setCaret(n),i.addItem(s),t(e),o=!0}
|
||||
return s="function"==typeof i.settings.create?i.settings.create.call(this,e,r):{[i.settings.labelField]:e,[i.settings.valueField]:e},o||r(s),!0}refreshItems(){var e=this
|
||||
e.lastQuery=null,e.isSetup&&e.addItems(e.items),e.updateOriginalInput(),e.refreshState()}refreshState(){const e=this
|
||||
e.refreshValidityState()
|
||||
const t=e.isFull(),s=e.isLocked
|
||||
e.wrapper.classList.toggle("rtl",e.rtl)
|
||||
const i=e.wrapper.classList
|
||||
var n
|
||||
i.toggle("focus",e.isFocused),i.toggle("disabled",e.isDisabled),i.toggle("readonly",e.isReadOnly),i.toggle("required",e.isRequired),i.toggle("invalid",!e.isValid),i.toggle("locked",s),i.toggle("full",t),i.toggle("input-active",e.isFocused&&!e.isInputHidden),i.toggle("dropdown-active",e.isOpen),i.toggle("has-options",(n=e.options,0===Object.keys(n).length)),i.toggle("has-items",e.items.length>0)}refreshValidityState(){var e=this
|
||||
e.input.validity&&(e.isValid=e.input.validity.valid,e.isInvalid=!e.isValid)}isFull(){return null!==this.settings.maxItems&&this.items.length>=this.settings.maxItems}updateOriginalInput(e={}){const t=this
|
||||
var s,i
|
||||
const n=t.input.querySelector('option[value=""]')
|
||||
if(t.is_select_tag){const o=[],r=t.input.querySelectorAll("option:checked").length
|
||||
function l(e,s,i){return e||(e=K('<option value="'+j(s)+'">'+j(i)+"</option>")),e!=n&&t.input.append(e),o.push(e),(e!=n||r>0)&&(e.selected=!0),e}t.input.querySelectorAll("option:checked").forEach((e=>{e.selected=!1})),0==t.items.length&&"single"==t.settings.mode?l(n,"",""):t.items.forEach((e=>{if(s=t.options[e],i=s[t.settings.labelField]||"",o.includes(s.$option)){l(t.input.querySelector(`option[value="${M(e)}"]:not(:checked)`),e,i)}else s.$option=l(s.$option,e,i)}))}else t.input.value=t.getValue()
|
||||
t.isSetup&&(e.silent||t.trigger("change",t.getValue()))}open(){var e=this
|
||||
e.isLocked||e.isOpen||"multi"===e.settings.mode&&e.isFull()||(e.isOpen=!0,se(e.focus_node,{"aria-expanded":"true"}),e.refreshState(),U(e.dropdown,{visibility:"hidden",display:"block"}),e.positionDropdown(),U(e.dropdown,{visibility:"visible",display:"block"}),e.focus(),e.trigger("dropdown_open",e.dropdown))}close(e=!0){var t=this,s=t.isOpen
|
||||
e&&(t.setTextboxValue(),"single"===t.settings.mode&&t.items.length&&t.inputState()),t.isOpen=!1,se(t.focus_node,{"aria-expanded":"false"}),U(t.dropdown,{display:"none"}),t.settings.hideSelected&&t.clearActiveOption(),t.refreshState(),s&&t.trigger("dropdown_close",t.dropdown)}positionDropdown(){if("body"===this.settings.dropdownParent){var e=this.control,t=e.getBoundingClientRect(),s=e.offsetHeight+t.top+window.scrollY,i=t.left+window.scrollX
|
||||
U(this.dropdown,{width:t.width+"px",top:s+"px",left:i+"px"})}}clear(e){var t=this
|
||||
if(t.items.length){var s=t.controlChildren()
|
||||
B(s,(e=>{t.removeItem(e,!0)})),t.inputState(),e||t.updateOriginalInput(),t.trigger("clear")}}insertAtCaret(e){const t=this,s=t.caretPos,i=t.control
|
||||
i.insertBefore(e,i.children[s]||null),t.setCaret(s+1)}deleteSelection(e){var t,s,i,n,o,r=this
|
||||
t=e&&8===e.keyCode?-1:1,s={start:(o=r.control_input).selectionStart||0,length:(o.selectionEnd||0)-(o.selectionStart||0)}
|
||||
const l=[]
|
||||
if(r.activeItems.length)n=ee(r.activeItems,t),i=te(n),t>0&&i++,B(r.activeItems,(e=>l.push(e)))
|
||||
else if((r.isFocused||"single"===r.settings.mode)&&r.items.length){const e=r.controlChildren()
|
||||
let i
|
||||
t<0&&0===s.start&&0===s.length?i=e[r.caretPos-1]:t>0&&s.start===r.inputValue().length&&(i=e[r.caretPos]),void 0!==i&&l.push(i)}if(!r.shouldDelete(l,e))return!1
|
||||
for(q(e,!0),void 0!==i&&r.setCaret(i);l.length;)r.removeItem(l.pop())
|
||||
return r.inputState(),r.positionDropdown(),r.refreshOptions(!1),!0}shouldDelete(e,t){const s=e.map((e=>e.dataset.value))
|
||||
return!(!s.length||"function"==typeof this.settings.onDelete&&!1===this.settings.onDelete(s,t))}advanceSelection(e,t){var s,i,n=this
|
||||
n.rtl&&(e*=-1),n.inputValue().length||(H(oe,t)||H("shiftKey",t)?(i=(s=n.getLastActive(e))?s.classList.contains("active")?n.getAdjacent(s,e,"item"):s:e>0?n.control_input.nextElementSibling:n.control_input.previousElementSibling)&&(i.classList.contains("active")&&n.removeActiveItem(s),n.setActiveItemClass(i)):n.moveCaret(e))}moveCaret(e){}getLastActive(e){let t=this.control.querySelector(".last-active")
|
||||
if(t)return t
|
||||
var s=this.control.querySelectorAll(".active")
|
||||
return s?ee(s,e):void 0}setCaret(e){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(e=this.isReadOnly||this.isDisabled){this.isLocked=e,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(e){this.focus_node.tabIndex=e?-1:this.tabIndex,this.isDisabled=e,this.input.disabled=e,this.control_input.disabled=e,this.setLocked()}setReadOnly(e){this.isReadOnly=e,this.input.readOnly=e,this.control_input.readOnly=e,this.setLocked()}destroy(){var e=this,t=e.revertSettings
|
||||
e.trigger("destroy"),e.off(),e.wrapper.remove(),e.dropdown.remove(),e.input.innerHTML=t.innerHTML,e.input.tabIndex=t.tabIndex,W(e.input,"tomselected","ts-hidden-accessible"),e._destroy(),delete e.input.tomselect}render(e,t){var s,i
|
||||
const n=this
|
||||
if("function"!=typeof this.settings.render[e])return null
|
||||
if(!(i=n.settings.render[e].call(this,t,j)))return null
|
||||
if(i=K(i),"option"===e||"option_create"===e?t[n.settings.disabledField]?se(i,{"aria-disabled":"true"}):se(i,{"data-selectable":""}):"optgroup"===e&&(s=t.group[n.settings.optgroupValueField],se(i,{"data-group":s}),t.group[n.settings.disabledField]&&se(i,{"data-disabled":""})),"option"===e||"item"===e){const s=N(t[n.settings.valueField])
|
||||
se(i,{"data-value":s}),"item"===e?(J(i,n.settings.itemClass),se(i,{"data-ts-item":""})):(J(i,n.settings.optionClass),se(i,{role:"option",id:t.$id}),t.$div=i,n.options[s]=t)}return i}_render(e,t){const s=this.render(e,t)
|
||||
if(null==s)throw"HTMLElement expected"
|
||||
return s}clearCache(){B(this.options,(e=>{e.$div&&(e.$div.remove(),delete e.$div)}))}uncacheValue(e){const t=this.getOption(e)
|
||||
t&&t.remove()}canCreate(e){return this.settings.create&&e.length>0&&this.settings.createFilter.call(this,e)}hook(e,t,s){var i=this,n=i[t]
|
||||
i[t]=function(){var t,o
|
||||
return"after"===e&&(t=n.apply(i,arguments)),o=s.apply(i,arguments),"instead"===e?o:("before"===e&&(t=n.apply(i,arguments)),t)}}}return ce.define("change_listener",(function(){D(this.input,"change",(()=>{this.sync()}))})),ce.define("checkbox_options",(function(e){var t=this,s=t.onOptionSelect
|
||||
t.settings.hideSelected=!1
|
||||
const i=Object.assign({className:"tomselect-checkbox",checkedClassNames:void 0,uncheckedClassNames:void 0},e)
|
||||
var n=function(e,t){t?(e.checked=!0,i.uncheckedClassNames&&e.classList.remove(...i.uncheckedClassNames),i.checkedClassNames&&e.classList.add(...i.checkedClassNames)):(e.checked=!1,i.checkedClassNames&&e.classList.remove(...i.checkedClassNames),i.uncheckedClassNames&&e.classList.add(...i.uncheckedClassNames))},o=function(e){setTimeout((()=>{var t=e.querySelector("input."+i.className)
|
||||
t instanceof HTMLInputElement&&n(t,e.classList.contains("selected"))}),1)}
|
||||
t.hook("after","setupTemplates",(()=>{var e=t.settings.render.option
|
||||
t.settings.render.option=(s,o)=>{var r=K(e.call(t,s,o)),l=document.createElement("input")
|
||||
i.className&&l.classList.add(i.className),l.addEventListener("click",(function(e){q(e)})),l.type="checkbox"
|
||||
const a=P(s[t.settings.valueField])
|
||||
return n(l,!!(a&&t.items.indexOf(a)>-1)),r.prepend(l),r}})),t.on("item_remove",(e=>{var s=t.getOption(e)
|
||||
s&&(s.classList.remove("selected"),o(s))})),t.on("item_add",(e=>{var s=t.getOption(e)
|
||||
s&&o(s)})),t.hook("instead","onOptionSelect",((e,i)=>{if(i.classList.contains("selected"))return i.classList.remove("selected"),t.removeItem(i.dataset.value),t.refreshOptions(),void q(e,!0)
|
||||
s.call(t,e,i),o(i)}))})),ce.define("clear_button",(function(e){const t=this,s=Object.assign({className:"clear-button",title:"Clear All",html:e=>`<div class="${e.className}" title="${e.title}">⨯</div>`},e)
|
||||
t.on("initialize",(()=>{var e=K(s.html(s))
|
||||
e.addEventListener("click",(e=>{t.isLocked||(t.clear(),"single"===t.settings.mode&&t.settings.allowEmptyOption&&t.addItem(""),e.preventDefault(),e.stopPropagation())})),t.control.appendChild(e)}))})),ce.define("drag_drop",(function(){var e=this
|
||||
if("multi"!==e.settings.mode)return
|
||||
var t=e.lock,s=e.unlock
|
||||
let i,n=!0
|
||||
e.hook("after","setupTemplates",(()=>{var t=e.settings.render.item
|
||||
e.settings.render.item=(s,o)=>{const r=K(t.call(e,s,o))
|
||||
se(r,{draggable:"true"})
|
||||
const l=e=>{e.preventDefault(),r.classList.add("ts-drag-over"),a(r,i)},a=(e,t)=>{var s,i,n
|
||||
void 0!==t&&(((e,t)=>{do{var s
|
||||
if(e==(t=null==(s=t)?void 0:s.previousElementSibling))return!0}while(t&&t.previousElementSibling)
|
||||
return!1})(t,r)?(i=t,null==(n=(s=e).parentNode)||n.insertBefore(i,s.nextSibling)):((e,t)=>{var s
|
||||
null==(s=e.parentNode)||s.insertBefore(t,e)})(e,t))}
|
||||
return D(r,"mousedown",(e=>{n||q(e),e.stopPropagation()})),D(r,"dragstart",(e=>{i=r,setTimeout((()=>{r.classList.add("ts-dragging")}),0)})),D(r,"dragenter",l),D(r,"dragover",l),D(r,"dragleave",(()=>{r.classList.remove("ts-drag-over")})),D(r,"dragend",(()=>{var t
|
||||
document.querySelectorAll(".ts-drag-over").forEach((e=>e.classList.remove("ts-drag-over"))),null==(t=i)||t.classList.remove("ts-dragging"),i=void 0
|
||||
var s=[]
|
||||
e.control.querySelectorAll("[data-value]").forEach((e=>{if(e.dataset.value){let t=e.dataset.value
|
||||
t&&s.push(t)}})),e.setValue(s)})),r}})),e.hook("instead","lock",(()=>(n=!1,t.call(e)))),e.hook("instead","unlock",(()=>(n=!0,s.call(e))))})),ce.define("dropdown_header",(function(e){const t=this,s=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:e=>'<div class="'+e.headerClass+'"><div class="'+e.titleRowClass+'"><span class="'+e.labelClass+'">'+e.title+'</span><a class="'+e.closeClass+'">×</a></div></div>'},e)
|
||||
t.on("initialize",(()=>{var e=K(s.html(s)),i=e.querySelector("."+s.closeClass)
|
||||
i&&i.addEventListener("click",(e=>{q(e,!0),t.close()})),t.dropdown.insertBefore(e,t.dropdown.firstChild)}))})),ce.define("caret_position",(function(){var e=this
|
||||
e.hook("instead","setCaret",(t=>{"single"!==e.settings.mode&&e.control.contains(e.control_input)?(t=Math.max(0,Math.min(e.items.length,t)))==e.caretPos||e.isPending||e.controlChildren().forEach(((s,i)=>{i<t?e.control_input.insertAdjacentElement("beforebegin",s):e.control.appendChild(s)})):t=e.items.length,e.caretPos=t})),e.hook("instead","moveCaret",(t=>{if(!e.isFocused)return
|
||||
const s=e.getLastActive(t)
|
||||
if(s){const i=te(s)
|
||||
e.setCaret(t>0?i+1:i),e.setActiveItem(),W(s,"last-active")}else e.setCaret(e.caretPos+t)}))})),ce.define("dropdown_input",(function(){const e=this
|
||||
e.settings.shouldOpen=!0,e.hook("before","setup",(()=>{e.focus_node=e.control,J(e.control_input,"dropdown-input")
|
||||
const t=K('<div class="dropdown-input-wrap">')
|
||||
t.append(e.control_input),e.dropdown.insertBefore(t,e.dropdown.firstChild)
|
||||
const s=K('<input class="items-placeholder" tabindex="-1" />')
|
||||
s.placeholder=e.settings.placeholder||"",e.control.append(s)})),e.on("initialize",(()=>{e.control_input.addEventListener("keydown",(t=>{switch(t.keyCode){case 27:return e.isOpen&&(q(t,!0),e.close()),void e.clearActiveItems()
|
||||
case 9:e.focus_node.tabIndex=-1}return e.onKeyDown.call(e,t)})),e.on("blur",(()=>{e.focus_node.tabIndex=e.isDisabled?-1:e.tabIndex})),e.on("dropdown_open",(()=>{e.control_input.focus()}))
|
||||
const t=e.onBlur
|
||||
e.hook("instead","onBlur",(s=>{if(!s||s.relatedTarget!=e.control_input)return t.call(e)})),D(e.control_input,"blur",(()=>e.onBlur())),e.hook("before","close",(()=>{e.isOpen&&e.focus_node.focus({preventScroll:!0})}))}))})),ce.define("input_autogrow",(function(){var e=this
|
||||
e.on("initialize",(()=>{var t=document.createElement("span"),s=e.control_input
|
||||
t.style.cssText="position:absolute; top:-99999px; left:-99999px; width:auto; padding:0; white-space:pre; ",e.wrapper.appendChild(t)
|
||||
for(const e of["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"])t.style[e]=s.style[e]
|
||||
var i=()=>{t.textContent=s.value,s.style.width=t.clientWidth+"px"}
|
||||
i(),e.on("update item_add item_remove",i),D(s,"input",i),D(s,"keyup",i),D(s,"blur",i),D(s,"update",i)}))})),ce.define("no_backspace_delete",(function(){var e=this,t=e.deleteSelection
|
||||
this.hook("instead","deleteSelection",(s=>!!e.activeItems.length&&t.call(e,s)))})),ce.define("no_active_items",(function(){this.hook("instead","setActiveItem",(()=>{})),this.hook("instead","selectAll",(()=>{}))})),ce.define("optgroup_columns",(function(){var e=this,t=e.onKeyDown
|
||||
e.hook("instead","onKeyDown",(s=>{var i,n,o,r
|
||||
if(!e.isOpen||37!==s.keyCode&&39!==s.keyCode)return t.call(e,s)
|
||||
e.ignoreHover=!0,r=Z(e.activeOption,"[data-group]"),i=te(e.activeOption,"[data-selectable]"),r&&(r=37===s.keyCode?r.previousSibling:r.nextSibling)&&(n=(o=r.querySelectorAll("[data-selectable]"))[Math.min(o.length-1,i)])&&e.setActiveOption(n)}))})),ce.define("remove_button",(function(e){const t=Object.assign({label:"×",title:"Remove",className:"remove",append:!0},e)
|
||||
var s=this
|
||||
if(t.append){var i='<a href="javascript:void(0)" class="'+t.className+'" tabindex="-1" title="'+j(t.title)+'">'+t.label+"</a>"
|
||||
s.hook("after","setupTemplates",(()=>{var e=s.settings.render.item
|
||||
s.settings.render.item=(t,n)=>{var o=K(e.call(s,t,n)),r=K(i)
|
||||
return o.appendChild(r),D(r,"mousedown",(e=>{q(e,!0)})),D(r,"click",(e=>{s.isLocked||(q(e,!0),s.isLocked||s.shouldDelete([o],e)&&(s.removeItem(o),s.refreshOptions(!1),s.inputState()))})),o}}))}})),ce.define("restore_on_backspace",(function(e){const t=this,s=Object.assign({text:e=>e[t.settings.labelField]},e)
|
||||
t.on("item_remove",(function(e){if(t.isFocused&&""===t.control_input.value.trim()){var i=t.options[e]
|
||||
i&&t.setTextboxValue(s.text.call(t,i))}}))})),ce.define("virtual_scroll",(function(){const e=this,t=e.canLoad,s=e.clearActiveOption,i=e.loadCallback
|
||||
var n,o,r={},l=!1,a=[]
|
||||
if(e.settings.shouldLoadMore||(e.settings.shouldLoadMore=()=>{if(n.clientHeight/(n.scrollHeight-n.scrollTop)>.9)return!0
|
||||
if(e.activeOption){var t=e.selectable()
|
||||
if(Array.from(t).indexOf(e.activeOption)>=t.length-2)return!0}return!1}),!e.settings.firstUrl)throw"virtual_scroll plugin requires a firstUrl() method"
|
||||
e.settings.sortField=[{field:"$order"},{field:"$score"}]
|
||||
const c=t=>!("number"==typeof e.settings.maxOptions&&n.children.length>=e.settings.maxOptions)&&!(!(t in r)||!r[t]),d=(t,s)=>e.items.indexOf(s)>=0||a.indexOf(s)>=0
|
||||
e.setNextUrl=(e,t)=>{r[e]=t},e.getUrl=t=>{if(t in r){const e=r[t]
|
||||
return r[t]=!1,e}return e.clearPagination(),e.settings.firstUrl.call(e,t)},e.clearPagination=()=>{r={}},e.hook("instead","clearActiveOption",(()=>{if(!l)return s.call(e)})),e.hook("instead","canLoad",(s=>s in r?c(s):t.call(e,s))),e.hook("instead","loadCallback",((t,s)=>{if(l){if(o){const s=t[0]
|
||||
void 0!==s&&(o.dataset.value=s[e.settings.valueField])}}else e.clearOptions(d)
|
||||
i.call(e,t,s),l=!1})),e.hook("after","refreshOptions",(()=>{const t=e.lastValue
|
||||
var s
|
||||
c(t)?(s=e.render("loading_more",{query:t}))&&(s.setAttribute("data-selectable",""),o=s):t in r&&!n.querySelector(".no-results")&&(s=e.render("no_more_results",{query:t})),s&&(J(s,e.settings.optionClass),n.append(s))})),e.on("initialize",(()=>{a=Object.keys(e.options),n=e.dropdown_content,e.settings.render=Object.assign({},{loading_more:()=>'<div class="loading-more-results">Loading more results ... </div>',no_more_results:()=>'<div class="no-more-results">No more results</div>'},e.settings.render),n.addEventListener("scroll",(()=>{e.settings.shouldLoadMore.call(e)&&c(e.lastValue)&&(l||(l=!0,e.load.call(e,e.lastValue)))}))}))})),ce}))
|
||||
var tomSelect=function(e,t){return new TomSelect(e,t)}
|
||||
18
shopping_app/templates/admin/_nav.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-4 admin-shortcuts">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-md-none mb-2">
|
||||
<button type="button" class="btn btn-outline-light w-100" data-admin-nav-toggle aria-expanded="false">☰ Menu admina</button>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2" data-admin-nav-body>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-sm {% if request.endpoint == 'admin_panel' %}btn-success{% else %}btn-outline-light{% endif %}">📊 Dashboard</a>
|
||||
<a href="{{ url_for('list_users') }}" class="btn btn-sm {% if request.endpoint == 'list_users' %}btn-success{% else %}btn-outline-light{% endif %}">👥 Użytkownicy</a>
|
||||
<a href="{{ url_for('admin_receipts') }}" class="btn btn-sm {% if request.endpoint == 'admin_receipts' %}btn-success{% else %}btn-outline-light{% endif %}">📸 Paragony</a>
|
||||
<a href="{{ url_for('list_products') }}" class="btn btn-sm {% if request.endpoint == 'list_products' %}btn-success{% else %}btn-outline-light{% endif %}">🛍️ Produkty</a>
|
||||
<a href="{{ url_for('admin_edit_categories') }}" class="btn btn-sm {% if request.endpoint == 'admin_edit_categories' %}btn-success{% else %}btn-outline-light{% endif %}">🗂 Kategorie</a>
|
||||
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-sm {% if request.endpoint == 'admin_lists_access' %}btn-success{% else %}btn-outline-light{% endif %}">🔐 Uprawnienia</a>
|
||||
<a href="{{ url_for('admin_api_tokens') }}" class="btn btn-sm {% if request.endpoint in ['admin_api_tokens', 'admin_api_docs'] %}btn-success{% else %}btn-outline-light{% endif %}">🔑 Tokeny API</a>
|
||||
<a href="{{ url_for('admin_templates') }}" class="btn btn-sm {% if request.endpoint == 'admin_templates' %}btn-success{% else %}btn-outline-light{% endif %}">🧩 Szablony</a>
|
||||
<a href="{{ url_for('admin_settings') }}" class="btn btn-sm {% if request.endpoint == 'admin_settings' %}btn-success{% else %}btn-outline-light{% endif %}">⚙️ Ustawienia</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
347
shopping_app/templates/admin/admin_panel.html
Normal file
@@ -0,0 +1,347 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Panel administratora{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="admin-page-head mb-4">
|
||||
<div>
|
||||
<h2 class="mb-2">⚙️ Panel administratora</h2>
|
||||
<p class="text-secondary mb-0">Wgląd w użytkowników, listy, paragony, wydatki i ustawienia aplikacji.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót do strony głównej</a>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Statystyki liczbowe -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">📊 Statystyki ogólne</h5>
|
||||
<table class="table table-dark table-sm mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>👤 Użytkownicy</td>
|
||||
<td class="text-end fw-bold">{{ user_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📝 Listy zakupowe</td>
|
||||
<td class="text-end fw-bold">{{ list_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🛒 Produkty na listach</td>
|
||||
<td class="text-end fw-bold">{{ item_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>✅ Zakupione</td>
|
||||
<td class="text-end fw-bold">{{ purchased_items_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🚫 Nieoznaczone jako kupione</td>
|
||||
<td class="text-end fw-bold">{{ not_purchased_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>✍️ Produkty z notatkami</td>
|
||||
<td class="text-end fw-bold">{{ items_with_notes }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🕓 Śr. czas do zakupu (h)</td>
|
||||
<td class="text-end fw-bold">{{ avg_hours_to_purchase }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>💸 Średnia kwota na listę</td>
|
||||
<td class="text-end fw-bold">{{ avg_list_expense }} zł</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr>
|
||||
<div class="small text-uppercase mb-1">📈 Średnie tempo tworzenia list:</div>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li>📆 Tygodniowo: <strong>{{ avg_per_week }}</strong></li>
|
||||
<li>🗓️ Miesięcznie: <strong>{{ avg_per_month }}</strong></li>
|
||||
<!--< li>📅 Rocznie: <strong>{{ avg_per_year }}</strong></li> -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Najczęściej kupowane -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark text-white h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">🔥 Najczęściej kupowane produkty</h5>
|
||||
{% if top_products %}
|
||||
{% set max_count = top_products[0][1] %}
|
||||
{% for name, count in top_products %}
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>{{ name }}</span>
|
||||
<span class="badge rounded-pill bg-secondary">{{ count }}x</span>
|
||||
</div>
|
||||
<div class="progress bg-transparent" style=" height: 6px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ (count / max_count) * 100 }}%"
|
||||
aria-valuenow="{{ count }}" aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div>
|
||||
<p><span class="badge rounded-pill bg-secondary opacity-75">Brak danych</span></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Podsumowanie wydatków -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark text-white h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">💸 Podsumowanie wydatków</h5>
|
||||
|
||||
<table class="table table-dark table-sm mb-3 align-middle">
|
||||
<thead class="text-muted small">
|
||||
<tr>
|
||||
<th title="Rodzaj listy zakupowej">Typ listy</th>
|
||||
<th title="Wydatki w bieżącym miesiącu">Miesiąc</th>
|
||||
<th title="Wydatki w bieżącym roku">Rok</th>
|
||||
<th title="Wydatki łączne">Całkowite</th>
|
||||
<!-- <th title="Średnia kwota na 1 listę">Średnia</th> -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Wszystkie</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.all.month) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.all.year) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.all.total) }} PLN</td>
|
||||
<!-- <td>{{ '%.2f'|format(expense_summary.all.avg) }} PLN</td> -->
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aktywne</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.active.month) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.active.year) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.active.total) }} PLN</td>
|
||||
<!-- <td>{{ '%.2f'|format(expense_summary.active.avg) }} PLN</td> -->
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Archiwalne</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.archived.month) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.archived.year) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.archived.total) }} PLN</td>
|
||||
<!-- <td>{{ '%.2f'|format(expense_summary.archived.avg) }} PLN</td> -->
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Wygasłe</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.expired.month) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.expired.year) }} PLN</td>
|
||||
<td>{{ '%.2f'|format(expense_summary.expired.total) }} PLN</td>
|
||||
<!-- <td>{{ '%.2f'|format(expense_summary.expired.avg) }} PLN</td> -->
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a href="{{ url_for('expenses') }}#chartTab" class="btn btn-outline-light w-100">
|
||||
📊 Pokaż wykres wydatków
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if expiring_lists %}<div class="alert alert-warning mb-4"><div class="fw-semibold mb-2">⏰ Listy tymczasowe wygasające w ciągu 24h</div><ul class="mb-0 ps-3">{% for l in expiring_lists %}<li>#{{ l.id }} {{ l.title }} — {{ l.owner.username if l.owner else '—' }} — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</li>{% endfor %}</ul></div>{% endif %}<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
|
||||
{# panel wyboru miesiąca zawsze widoczny #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
|
||||
{# LEWA STRONA — przyciski ← → TYLKO gdy nie show_all #}
|
||||
<div class="d-flex gap-2">
|
||||
{% if not show_all %}
|
||||
{% set current_date = now.replace(day=1) %}
|
||||
{% set prev_month = (current_date - timedelta(days=1)).strftime('%Y-%m') %}
|
||||
{% set next_month = (current_date + timedelta(days=31)).replace(day=1).strftime('%Y-%m') %}
|
||||
|
||||
{% if prev_month in month_options %}
|
||||
<a href="{{ url_for('admin_panel', m=prev_month) }}" class="btn btn-outline-light btn-sm">
|
||||
← {{ prev_month }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-light btn-sm opacity-50" disabled>← {{ prev_month }}</button>
|
||||
{% endif %}
|
||||
|
||||
{% if next_month in month_options %}
|
||||
<a href="{{ url_for('admin_panel', m=next_month) }}" class="btn btn-outline-light btn-sm">
|
||||
{{ next_month }} →
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-light btn-sm opacity-50" disabled>{{ next_month }} →</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Tryb wszystkie miesiące — możemy pokazać skrót do bieżącego miesiąca #}
|
||||
<a href="{{ url_for('admin_panel', m=now.strftime('%Y-%m')) }}" class="btn btn-outline-light btn-sm">
|
||||
📅 Przejdź do bieżącego miesiąca
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# PRAWA STRONA — picker miesięcy zawsze widoczny #}
|
||||
<form method="get" class="m-0">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-secondary text-white">📅</span>
|
||||
<select name="m" class="form-select bg-dark text-white border-secondary" onchange="this.form.submit()">
|
||||
<option value="all" {% if show_all %}selected{% endif %}>Wszystkie miesiące</option>
|
||||
{% for val in month_options %}
|
||||
{% set date_obj = (val ~ '-01') | todatetime %}
|
||||
<option value="{{ val }}" {% if month_str==val %}selected{% endif %}>
|
||||
{{ date_obj.strftime('%B %Y')|capitalize }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-4">
|
||||
📄 Listy zakupowe
|
||||
{% if show_all %}
|
||||
— <strong>wszystkie miesiące</strong>
|
||||
{% else %}
|
||||
— <strong>{{ month_str|replace('-', ' / ') }}</strong>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<form method="post" action="{{ url_for('admin_delete_list') }}"
|
||||
onsubmit="return confirm('Na pewno usunąć tę listę?')" class="d-inline">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="select-all"></th>
|
||||
<th>ID</th>
|
||||
<th>Tytuł</th>
|
||||
<th>Status</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Właściciel</th>
|
||||
<th>Produkty</th>
|
||||
<th>Progress</th>
|
||||
<th>Koment.</th>
|
||||
<th>Paragony</th>
|
||||
<th>Wydatki</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in enriched_lists %}
|
||||
{% set l = e.list %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="list_ids" value="{{ l.id }}" class="table-select-checkbox"></td>
|
||||
<td>{{ l.id }}</td>
|
||||
<td class="fw-bold align-middle">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>{% if l.is_temporary and l.expires_at %}<div class="small text-warning mt-1">wygasa: {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}</div>{% endif %}
|
||||
{% if l.categories %}
|
||||
<span class="ms-1 text-info" data-bs-toggle="tooltip"
|
||||
title="{{ l.categories | map(attribute='name') | join(', ') }}">
|
||||
🏷
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if l.is_archived %}
|
||||
<span class="badge rounded-pill bg-secondary">Archiwalna</span>
|
||||
{% elif e.expired %}
|
||||
<span class="badge rounded-pill bg-warning text-dark">Wygasła</span>
|
||||
{% else %}
|
||||
<span class="badge rounded-pill bg-success">Aktywna</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if l.owner %}
|
||||
👤 {{ l.owner.username }} ({{ l.owner.id }})
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.total_count }}</td>
|
||||
<td>
|
||||
<div class="progress bg-transparent" style=" height: 14px;">
|
||||
<div class="progress-bar fw-bold text-black text-cente
|
||||
{% if e.percent >= 80 %}bg-success
|
||||
{% elif e.percent >= 40 %}bg-warning
|
||||
{% else %}bg-danger{% endif %}" role="progressbar" style="width: {{ e.percent }}%">
|
||||
{{ e.purchased_count }}/{{ e.total_count }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge rounded-pill bg-primary">{{ e.comments_count }}</span></td>
|
||||
<td><span class="badge rounded-pill bg-secondary">{{ e.receipts_count }}</span></td>
|
||||
<td class="fw-bold
|
||||
{% if e.total_expense >= 500 %}text-danger
|
||||
{% elif e.total_expense > 0 %}text-success{% endif %}">
|
||||
{% if e.total_expense > 0 %}
|
||||
{{ '%.2f'|format(e.total_expense) }} PLN
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="{{ url_for('edit_list', list_id=l.id) }}" class="btn btn-sm btn-outline-light" title="Edytuj">✏️</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-light preview-btn" data-list-id="{{ l.id }}" title="Podgląd produktów">👁️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if enriched_lists|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4">
|
||||
Brak list zakupowych do wyświetlenia
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-2">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">🗑️ Usuń zaznaczone listy</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-bar-fixed">
|
||||
Python: {{ python_version.split()[0] }} | {{ system_info }} | RAM app: {{ app_memory }} |
|
||||
DB: {{ db_info.engine|upper }}{% if db_info.version %} v{{ db_info.version[0] }}{% endif %} |
|
||||
Tabele: {{ table_count }} | Rekordy: {{ record_total }} |
|
||||
Uptime: {{ uptime_minutes }} min
|
||||
</div>
|
||||
|
||||
<!-- Modal podglądu produktów -->
|
||||
<div class="modal fade" id="productPreviewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="previewModalLabel">Podgląd produktów</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul id="product-list" class="list-group list-group-flush"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('select-all').addEventListener('click', function () {
|
||||
const checkboxes = document.querySelectorAll('input[name="list_ids"]');
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
});
|
||||
</script>
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'preview_list_modal.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
161
shopping_app/templates/admin/api_tokens.html
Normal file
@@ -0,0 +1,161 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Tokeny API{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
|
||||
<div>
|
||||
<h2 class="mb-2">🔑 Tokeny API</h2>
|
||||
<p class="text-secondary mb-0">Administrator może utworzyć wiele tokenów, ograniczyć ich zakres i endpointy oraz w każdej chwili je wyłączyć albo usunąć.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-outline-light" target="_blank">📄 Zobacz opis API</a>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
{% if latest_plain_token %}
|
||||
<div class="alert alert-success border-success mb-4" role="alert">
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div><strong>Nowy token:</strong> {{ latest_api_token_name or 'API' }}</div>
|
||||
<div class="input-group">
|
||||
<input type="text" id="latestApiToken" class="form-control" readonly value="{{ latest_plain_token }}">
|
||||
<button type="button" class="btn btn-outline-light" data-copy-target="#latestApiToken">📋 Kopiuj</button>
|
||||
</div>
|
||||
<div class="small text-warning">Pełna wartość jest widoczna tylko teraz. Po odświeżeniu zostanie ukryta.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card bg-dark text-white">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">➕ Utwórz token</h5>
|
||||
<form method="post" data-unsaved-warning="true" class="stack-form">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-4">
|
||||
<label for="name" class="form-label">Nazwa tokenu</label>
|
||||
<input type="text" id="name" name="name" class="form-control" placeholder="np. integracja ERP / Power BI" required>
|
||||
<div class="form-text">Nazwij token tak, aby było wiadomo do czego służy.</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<label class="form-label d-block">Zakresy</label>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_expenses_read" name="scope_expenses_read" checked><label class="form-check-label" for="scope_expenses_read">Odczyt wydatków</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_lists_read" name="scope_lists_read" checked><label class="form-check-label" for="scope_lists_read">Odczyt list i wydatków list</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="scope_templates_read" name="scope_templates_read"><label class="form-check-label" for="scope_templates_read">Odczyt szablonów</label></div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<label class="form-label d-block">Dozwolone endpointy</label>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_ping" name="allow_ping" checked><label class="form-check-label" for="allow_ping">/api/ping</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_latest_expenses" name="allow_latest_expenses" checked><label class="form-check-label" for="allow_latest_expenses">/api/expenses/latest</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_expenses_summary" name="allow_expenses_summary" checked><label class="form-check-label" for="allow_expenses_summary">/api/expenses/summary</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_lists" name="allow_lists" checked><label class="form-check-label" for="allow_lists">/api/lists oraz /api/lists/<id>/expenses</label></div>
|
||||
<div class="form-check form-check-spaced"><input class="form-check-input" type="checkbox" id="allow_templates" name="allow_templates"><label class="form-check-label" for="allow_templates">/api/templates</label></div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<label for="max_limit" class="form-label">Maksymalny limit rekordów</label>
|
||||
<input type="number" id="max_limit" name="max_limit" min="1" max="500" value="100" class="form-control">
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-success w-100">🔑 Wygeneruj token</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mt-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||
<h5 class="mb-0">📘 Dokumentacja API</h5>
|
||||
<a href="{{ url_for('admin_api_docs') }}" class="btn btn-sm btn-outline-light" target="_blank">Otwórz TXT</a>
|
||||
</div>
|
||||
<div class="small text-secondary mb-3">Autoryzacja: <code>Authorization: Bearer TWOJ_TOKEN</code> lub <code>X-API-Token</code>. Endpoint i zakres muszą być jednocześnie dozwolone na tokenie. Parametr <code>limit</code> jest przycinany do wartości ustawionej w tokenie.</div>
|
||||
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
|
||||
<table class="table table-dark align-middle table-sm keep-horizontal">
|
||||
<thead>
|
||||
<tr><th>Metoda</th><th>Endpoint</th><th>Wymagany zakres</th><th>Opis</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in api_examples %}
|
||||
<tr>
|
||||
<td><code>{{ row.method }}</code></td>
|
||||
<td><code class="api-chip api-chip--wrap">{{ row.path }}</code></td>
|
||||
<td><code class="api-chip">{{ row.scope }}</code></td>
|
||||
<td>{{ row.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mt-4">
|
||||
<div class="card-body">
|
||||
<div class="admin-page-head mb-3">
|
||||
<h5 class="mb-0">📋 Aktywne i historyczne tokeny</h5>
|
||||
<span class="badge rounded-pill bg-secondary">{{ api_tokens|length }} szt.</span>
|
||||
</div>
|
||||
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
|
||||
<table class="table table-dark align-middle sortable keep-horizontal" data-searchable="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nazwa</th>
|
||||
<th>Prefix</th>
|
||||
<th>Status</th>
|
||||
<th>Zakres</th>
|
||||
<th>Endpointy</th>
|
||||
<th>Max limit</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Ostatnie użycie</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for token in api_tokens %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold text-break">{{ token.name }}</div>
|
||||
<div class="small text-secondary">Autor: {{ token.creator.username if token.creator else '—' }}</div>
|
||||
</td>
|
||||
<td><code class="api-chip">{{ token.token_prefix }}…</code></td>
|
||||
<td>{% if token.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
|
||||
<td><code class="api-chip api-chip--wrap">{{ token.scopes or '—' }}</code></td>
|
||||
<td><code class="api-chip api-chip--wrap">{{ token.allowed_endpoints or '—' }}</code></td>
|
||||
<td>{{ token.max_limit or '—' }}</td>
|
||||
<td>{{ token.created_at.strftime('%Y-%m-%d %H:%M') if token.created_at else '—' }}</td>
|
||||
<td>{{ token.last_used_at.strftime('%Y-%m-%d %H:%M') if token.last_used_at else 'Jeszcze nie użyto' }}</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% if token.is_active %}
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="deactivate">
|
||||
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning">⏸ Wyłącz</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="activate">
|
||||
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">▶ Włącz</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" class="d-inline" onsubmit="return confirm('Usunąć ten token API?')">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">🗑 Usuń</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="text-center text-secondary py-4">Brak tokenów API.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
151
shopping_app/templates/admin/edit_categories.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Masowa edycja kategorii{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">🗂 Masowa edycja kategorii</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning border-warning text-dark" role="alert">
|
||||
⚠️ <strong>Uwaga!</strong> Przypisanie więcej niż jednej kategorii do listy może zaburzyć poprawne zliczanie
|
||||
wydatków.
|
||||
</div>
|
||||
|
||||
<form method="post" id="mass-edit-form" data-unsaved-warning="true">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive admin-table-responsive admin-table-responsive--full">
|
||||
<table class="table table-dark align-middle sortable mb-0">
|
||||
<thead class="position-sticky top-0 bg-dark">
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Nazwa listy</th>
|
||||
<th scope="col">Właściciel</th>
|
||||
<th scope="col">Data</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Podgląd</th>
|
||||
<th scope="col" style="min-width: 260px;">Kategorie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in lists %}
|
||||
<tr>
|
||||
<td>{{ l.id }}</td>
|
||||
<td class="fw-bold align-middle">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title
|
||||
}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if l.owner %}
|
||||
👤 {{ l.owner.username }} ({{ l.owner.id }})
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
<td>
|
||||
{% if l.is_archived %}<span
|
||||
class="badge rounded-pill bg-secondary me-1">Archiwalna</span>{% endif %}
|
||||
{% if l.is_temporary %}<span
|
||||
class="badge rounded-pill bg-warning text-dark me-1">Tymczasowa</span>{%
|
||||
endif %}
|
||||
{% if l.is_public %}<span class="badge rounded-pill bg-success">Publiczna</span>
|
||||
{% else %}<span class="badge rounded-pill bg-dark">Prywatna</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-light preview-btn"
|
||||
data-list-id="{{ l.id }}">
|
||||
🔍 Zobacz
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select name="categories_{{ l.id }}" multiple
|
||||
class="form-select tom-dark bg-dark text-white border-secondary rounded"
|
||||
data-list-id="{{ l.id }}"
|
||||
aria-label="Wybierz kategorie dla listy {{ l.id }}">
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if cat in l.categories %}selected{%
|
||||
endif %}>{{ cat.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if lists|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4">Brak list zakupowych do wyświetlenia</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3"><button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz wszystkie zmiany</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<form method="get" class="d-flex align-items-center">
|
||||
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
|
||||
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
|
||||
onchange="this.form.page.value = 1; this.form.submit();">
|
||||
<option value="25" {% if per_page==25 %}selected{% endif %}>25</option>
|
||||
<option value="50" {% if per_page==50 %}selected{% endif %}>50</option>
|
||||
<option value="100" {% if per_page==100 %}selected{% endif %}>100</option>
|
||||
</select>
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
</form>
|
||||
|
||||
<nav aria-label="Nawigacja stron">
|
||||
<ul class="pagination pagination-dark mb-0">
|
||||
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
|
||||
</li>
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Modal podglądu produktów -->
|
||||
<div class="modal fade" id="productPreviewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="previewModalLabel">Podgląd produktów</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
|
||||
aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul id="product-list" class="list-group list-group-flush"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'preview_list_modal.js') }}"></script>
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'categories_select_admin.js') }}"></script>
|
||||
{% endblock %}
|
||||
307
shopping_app/templates/admin/edit_list.html
Normal file
@@ -0,0 +1,307 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Edytuj listę{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">🛠️ Edytuj listę #{{ list.id }}</h2>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">📄 Podstawowe informacje</h4>
|
||||
<form method="post" class="mt-3" data-unsaved-warning="true">
|
||||
<input type="hidden" name="action" value="save">
|
||||
|
||||
<!-- Nazwa listy -->
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">📝 Nazwa listy</label>
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" id="title" name="title"
|
||||
value="{{ list.title }}" required>
|
||||
</div>
|
||||
|
||||
<!-- Wydatek i właściciel -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="amount" class="form-label">💰 Całkowity wydatek (PLN)</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary rounded"
|
||||
id="amount" name="amount" value="{{ '%.2f'|format(total_expense) }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="owner_id" class="form-label">👤 Właściciel</label>
|
||||
<select class="form-select bg-dark text-white border-secondary" id="owner_id" name="owner_id">
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if list.owner_id==user.id %}selected{% endif %}>
|
||||
{{ user.username }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statusy -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">⚙️ Statusy listy</label>
|
||||
<div class="switch-grid">
|
||||
<div class="form-check form-switch app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="archived" name="archived" {% if list.is_archived
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="archived">📦 Archiwalna</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="public" name="public" {% if list.is_public %}checked{%
|
||||
endif %}>
|
||||
<label class="form-check-label" for="public">🌐 Publiczna</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch app-switch">
|
||||
<input class="form-check-input" type="checkbox" id="temporary" name="temporary" {% if list.is_temporary
|
||||
%}checked{% endif %}>
|
||||
<label class="form-check-label" for="temporary">⏳ Tymczasowa (podaj date i godzine wygasania)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Data/godzina wygaśnięcia -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="expires_date" class="form-label">📅 Data wygaśnięcia</label>
|
||||
<input type="date" class="form-control bg-dark text-white border-secondary rounded" id="expires_date"
|
||||
name="expires_date" value="{{ list.expires_at.strftime('%Y-%m-%d') if list.expires_at else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="expires_time" class="form-label">⏰ Godzina wygaśnięcia</label>
|
||||
<input type="time" class="form-control bg-dark text-white border-secondary rounded" id="expires_time"
|
||||
name="expires_time" value="{{ list.expires_at.strftime('%H:%M') if list.expires_at else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Utworzono / Zmień miesiąc -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">📆 Utworzono</label>
|
||||
<div>
|
||||
<span class="badge rounded-pill bg-success rounded-pill text-dark ms-1">
|
||||
{{ list.created_at.strftime('%Y-%m-%d') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">📁 Przenieś do miesiąca (format: rok-miesiąc np 2026-01)</label>
|
||||
<input type="month" id="created_month" name="created_month"
|
||||
class="form-control bg-dark text-white border-secondary rounded">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kategorie -->
|
||||
<div class="mb-4">
|
||||
<label for="categories" class="form-label">🏷️ Kategorie</label>
|
||||
<select id="categories" name="categories"
|
||||
class="form-select tom-dark bg-dark text-white border-secondary rounded">
|
||||
<option value="">– brak –</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}" {% if cat.id in selected_categories %}selected{% endif %}>
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Link udostępnienia -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">🔗 Link do udostępnienia</label>
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" readonly
|
||||
value="{{ request.url_root }}share/{{ list.share_token }}">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Dostęp / uprawnienia -->
|
||||
<div class="mb-4 border-top pt-3 mt-4">
|
||||
<h5 class="mb-3">🔐 Użytkownicy z dostępem</h5>
|
||||
|
||||
<a class="btn btn-outline-warning btn-sm mb-3" href="{{ url_for('admin_lists_access', list_id=list.id) }}">
|
||||
⚙️ Edytuj uprawnienia
|
||||
</a>
|
||||
|
||||
{% if permitted_users %}
|
||||
<ul class="list-group list-group-flush mb-3">
|
||||
{% for u in permitted_users %}
|
||||
<li
|
||||
class="list-group-item bg-dark text-white d-flex justify-content-between align-items-center border-secondary">
|
||||
<div>
|
||||
<span class="fw-semibold">@{{ u.username }}</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="text-warning small">Brak dodatkowych uprawnień.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-outline-light btn-sm me-2">💾 Zapisz zmiany</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">📝 Produkty</h4>
|
||||
|
||||
<form method="post" class="row g-2 mb-3">
|
||||
<input type="hidden" name="action" value="add_item">
|
||||
<div class="col-md-8">
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary rounded" name="item_name"
|
||||
placeholder="Nazwa produktu" required>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<input type="number" class="form-control bg-dark text-white border-secondary rounded" name="quantity" min="1"
|
||||
value="1">
|
||||
</div>
|
||||
<div class="col-md-3 d-grid">
|
||||
<button type="submit" class="btn btn-outline-light">➕ Dodaj</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-bordered align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nazwa produktu</th>
|
||||
<th>Notatka</th>
|
||||
<th>Ilość</th>
|
||||
<th>Aktualny stan</th>
|
||||
<th>Akcja</th>
|
||||
<th>Usuń</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ item.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.note %}
|
||||
<div class="text-info small mt-1"><strong>Notatka:</strong> {{ item.note }}</div>
|
||||
{% endif %}
|
||||
{% if item.not_purchased_reason %}
|
||||
<div class="text-warning small mt-1"><strong>Powód:</strong> {{ item.not_purchased_reason }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}">
|
||||
<input type="hidden" name="action" value="edit_quantity">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<div class="input-group input-group-sm w-auto">
|
||||
<input type="number" name="quantity" class="form-control bg-dark text-white border-secondary" min="1"
|
||||
value="{{ item.quantity }}">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">💾</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.purchased %}
|
||||
<span class="badge bg-success">✔️ Kupiony</span>
|
||||
{% elif item.not_purchased %}
|
||||
<span class="badge bg-warning text-dark">⚠️ Nie kupiony</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Nieoznaczony</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<div class="btn-group btn-group-sm d-flex gap-1">
|
||||
{% if not item.not_purchased %}
|
||||
<button type="submit" name="action" value="toggle_purchased" class="btn btn-outline-light btn-sm">
|
||||
{{ '🚫 Odznacz' if item.purchased else '✅ Kupiony' }}
|
||||
</button>
|
||||
<button type="submit" name="action" value="mark_not_purchased" class="btn btn-outline-light btn-sm">⚠️
|
||||
Nie kupiony</button>
|
||||
{% endif %}
|
||||
{% if item.not_purchased %}
|
||||
<button type="submit" name="action" value="unmark_not_purchased"
|
||||
class="btn btn-outline-light btn-sm">✅
|
||||
Przywróć jako nieoznaczone</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('edit_list', list_id=list.id) }}">
|
||||
<input type="hidden" name="action" value="delete_item">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">Brak produktów.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">🧾 Paragony</h4>
|
||||
|
||||
<div class="mb-3 text-end">
|
||||
<a href="{{ url_for('admin_receipts', id=list.id) }}" class="btn btn-sm btn-outline-light">
|
||||
📂 Otwórz zarządzanie paragonami
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for r in receipts %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100 shadow-sm border border-secondary">
|
||||
<a href="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}" class="glightbox"
|
||||
data-gallery="receipts" data-title="{{ r.filename }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}"
|
||||
class="card-img-top" style="object-fit: cover; height: 200px;" title="{{ r.filename }}">
|
||||
</a>
|
||||
|
||||
<div class="card-body text-center p-2 small">
|
||||
<div class="text-truncate fw-semibold" title="{{ r.filename }}">📄 {{ r.filename }}</div>
|
||||
<div>📅 {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</div>
|
||||
<div>👤 {{ r.uploaded_by_user.username if r.uploaded_by_user else "?" }}</div>
|
||||
<div>
|
||||
💾
|
||||
{% if r.filesize and r.filesize >= 1024 * 1024 %}
|
||||
{{ (r.filesize / 1024 / 1024) | round(2) }} MB
|
||||
{% elif r.filesize %}
|
||||
{{ (r.filesize / 1024) | round(1) }} kB
|
||||
{% else %}
|
||||
Brak danych
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not receipts %}
|
||||
<div class="alert alert-info text-center mt-3" role="alert">
|
||||
ℹ️ Brak paragonów
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'select.js') }}"></script>
|
||||
{% endblock %}
|
||||
177
shopping_app/templates/admin/list_products.html
Normal file
@@ -0,0 +1,177 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Produkty i sugestie{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">🛍️ Produkty i sugestie</h2>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Formularz dodawania sugestii -->
|
||||
<form action="{{ url_for('add_suggestion') }}" method="POST" class="mb-4">
|
||||
<label class="form-label fw-bold mb-2">➕ Dodaj nową sugestię:</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="suggestion_name" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Nowa sugestia produktu…" required>
|
||||
<button type="submit" class="btn btn-outline-light">➕ Dodaj</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="border-secondary opacity-50 mb-4 mt-2">
|
||||
|
||||
<!-- Szukajka z przyciskiem wyczyść -->
|
||||
<label for="search-table" class="form-label fw-bold mb-2">🔍 Przeszukaj tabelę produktów i sugestii:</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="search-table" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Wpisz frazę, np. 'mleko'">
|
||||
<button type="button" id="clear-search" class="btn btn-outline-light">🧹 Wyczyść</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="m-0">📦 Produkty (z synchronizacją sugestii o unikalnych nazwach)</h4>
|
||||
<span class="badge rounded-pill bg-info">{{ total_items }} produktów</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark align-middle sortable keep-horizontal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nazwa</th>
|
||||
<th>Dodany przez</th>
|
||||
<th>Ilość użyć</th>
|
||||
<th>Akcja</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td class="fw-bold"><span class="badge rounded-pill bg-primary">{{ item.name }}</span></td>
|
||||
<td>
|
||||
{% if item.added_by and users_dict.get(item.added_by) %}
|
||||
👤 {{ users_dict[item.added_by] }} ({{ item.added_by }})
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge rounded-pill bg-secondary">{{ usage_counts.get(item.name.lower(), 0) }}</span></td>
|
||||
<td>
|
||||
{% set clean_name = item.name | replace('\xa0', ' ') | trim | lower %}
|
||||
{% set suggestion = suggestions_dict.get(clean_name) %}
|
||||
{% if suggestion %}
|
||||
✅ Istnieje (ID: {{ suggestion.id }})
|
||||
<button class="btn btn-sm btn-outline-light ms-1 delete-suggestion-btn"
|
||||
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-outline-light sync-btn" data-item-id="{{ item.id }}">🔄
|
||||
Synchronizuj</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if items|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4">
|
||||
Pusta lista produktów
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="m-0">💡 Wszystkie sugestie (poza powiązanymi)</h4>
|
||||
<span class="badge rounded-pill bg-info">{{ orphan_suggestions|length }} sugestii</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% set item_names = items | map(attribute='name') | map('lower') | list %}
|
||||
<table class="table table-dark align-middle sortable keep-horizontal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nazwa</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for suggestion in orphan_suggestions %}
|
||||
{% if suggestion.name.lower() not in item_names %}
|
||||
<tr>
|
||||
<td>{{ suggestion.id }}</td>
|
||||
<td class="fw-bold"><span class="badge rounded-pill bg-primary">{{ suggestion.name }}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-light delete-suggestion-btn"
|
||||
data-suggestion-id="{{ suggestion.id }}">🗑️ Usuń</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if orphan_suggestions|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4">
|
||||
Brak niepowiązanych sugestii do wyświetlenia
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<form method="get" class="d-flex align-items-center">
|
||||
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
|
||||
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
|
||||
onchange="this.form.page.value = 1; this.form.submit();">
|
||||
<option value="100" {% if per_page==25 %}selected{% endif %}>100</option>
|
||||
<option value="200" {% if per_page==50 %}selected{% endif %}>200</option>
|
||||
<option value="300" {% if per_page==100 %}selected{% endif %}>300</option>
|
||||
<option value="500" {% if per_page==500 %}selected{% endif %}>500</option>
|
||||
<option value="750" {% if per_page==750 %}selected{% endif %}>750</option>
|
||||
<option value="1000" {% if per_page==1000 %}selected{% endif %}>1000</option>
|
||||
</select>
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
</form>
|
||||
|
||||
<nav aria-label="Nawigacja stron">
|
||||
<ul class="pagination pagination-dark mb-0">
|
||||
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
|
||||
</li>
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{
|
||||
p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'product_suggestion.js') }}"></script>
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'table_search.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
187
shopping_app/templates/admin/lists_access.html
Normal file
@@ -0,0 +1,187 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Zarządzanie dostępem do list{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-3">
|
||||
<h2 class="mb-2">🔐{% if list_id %} Dostęp do listy #{{ list_id }}{% else %} Zarządzanie dostępem do list{% endif %}</h2>
|
||||
<div class="d-flex gap-2">
|
||||
{% if list_id %}
|
||||
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light">Powrót do wszystkich list</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<!-- STICKY ACTION BAR -->
|
||||
<div id="bulkBar" class="position-sticky top-0 z-3 mb-3" style="backdrop-filter: blur(6px);">
|
||||
<div class="card bg-dark border-secondary shadow-sm">
|
||||
<div class="card-body py-2 d-flex flex-wrap align-items-center gap-3">
|
||||
<div class="d-flex align-items-center gap-2 w-100">
|
||||
<input id="selectAll" class="form-check-input table-select-checkbox" type="checkbox" />
|
||||
<label for="selectAll" class="form-check-label">Zaznacz wszystko</label>
|
||||
</div>
|
||||
|
||||
<div class="vr text-secondary"></div>
|
||||
|
||||
<div class="flex-grow-1 d-flex align-items-center gap-2">
|
||||
<input id="listFilter" class="form-control form-control-sm bg-dark text-white border-secondary"
|
||||
placeholder="Szukaj po tytule/ID/właścicielu…" aria-label="Filtruj listy">
|
||||
<span class="text-secondary small ms-1" id="filterCount">—</span>
|
||||
</div>
|
||||
|
||||
<div class="vr text-secondary d-none d-md-block"></div>
|
||||
|
||||
<!-- BULK GRANT -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="input-group input-group-sm">
|
||||
<input id="bulkUsersInput" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints" autocomplete="off">
|
||||
<button id="bulkAddBtn" class="btn btn-outline-light" type="button">➕ Nadaj dostęp</button>
|
||||
</div>
|
||||
<div id="bulkTokens" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- HINTS -->
|
||||
<datalist id="userHints">
|
||||
{% for username in all_usernames %}<option value="{{ username }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive admin-table-responsive admin-table-responsive--wide">
|
||||
<table class="table table-dark align-middle sortable lists-access-table" id="listsTable">
|
||||
<thead class="align-middle">
|
||||
<tr>
|
||||
<th scope="col" style="width:36px;"></th>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Nazwa listy</th>
|
||||
<th scope="col">Właściciel</th>
|
||||
<th scope="col">Utworzono</th>
|
||||
<th scope="col">Udostępnianie</th>
|
||||
<th scope="col" style="min-width: 420px;">Uprawnienia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in lists %}
|
||||
<tr data-id="{{ l.id }}" data-title="{{ l.title|lower }}"
|
||||
data-owner="{{ (l.owner.username if l.owner else '-')|lower }}">
|
||||
<td>
|
||||
<input class="row-check form-check-input table-select-checkbox" type="checkbox" data-list-id="{{ l.id }}">
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap">{{ l.id }}</td>
|
||||
|
||||
<td class="fw-bold align-middle">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white text-decoration-none">{{ l.title
|
||||
}}</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if l.owner %}
|
||||
👤 <span class="owner-username" data-username="{{ l.owner.username }}">@{{ l.owner.username }}</span>
|
||||
({{ l.owner.id }})
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap">{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
<td style="min-width: 260px;">
|
||||
{% if l.share_token %}
|
||||
{% set share_url = url_for('shared_list', token=l.share_token, _external=True) %}
|
||||
<div class="d-flex align-items-center gap-2 w-100">
|
||||
<div class="flex-grow-1 text-truncate mono small" title="{{ share_url }}">{{ share_url }}</div>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-share" type="button" data-url="{{ share_url }}"
|
||||
aria-label="Kopiuj link">📋</button>
|
||||
</div>
|
||||
<div class="text-info small mt-1">
|
||||
{% if l.is_public %}Lista widoczna publicznie{% else %}Dostęp przez link / uprawnienia{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-warning small w-100 d-block">Brak tokenu</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="access-editor" data-list-id="{{ l.id }}" data-post-url="{{ request.path }}{{ ('?page=' ~ page ~ "&per_page=" ~ per_page) if not list_id else "" }}" data-suggest-url="{{ url_for('admin_user_suggestions') }}" data-next="{{ request.full_path if request.query_string else request.path }}">
|
||||
<!-- Tokeny z uprawnieniami -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-2 tokens">
|
||||
{% for u in permitted_by_list.get(l.id, []) %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill token"
|
||||
data-user-id="{{ u.id }}" data-username="{{ u.username }}" title="Kliknij, aby odebrać dostęp">
|
||||
@{{ u.username }} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% if permitted_by_list.get(l.id, [])|length == 0 %}
|
||||
<span class="text-warning small no-perms">Brak dodanych uprawnień.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Dodawanie (wiele na raz) -->
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm bg-dark text-white border-secondary access-input"
|
||||
placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHints" autocomplete="off"
|
||||
aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="btn btn-sm btn-outline-light access-add">💾 Zapisz dostęp</button>
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp. Zmiana zapisuje się od razu.</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if lists|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4">Brak list do wyświetlenia</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not list_id %}
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<form method="get" class="d-flex align-items-center">
|
||||
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
|
||||
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
|
||||
onchange="this.form.page.value = 1; this.form.submit();">
|
||||
<option value="25" {% if per_page==25 %}selected{% endif %}>25</option>
|
||||
<option value="50" {% if per_page==50 %}selected{% endif %}>50</option>
|
||||
<option value="100" {% if per_page==100 %}selected{% endif %}>100</option>
|
||||
</select>
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
</form>
|
||||
|
||||
<nav aria-label="Nawigacja stron">
|
||||
<ul class="pagination pagination-dark mb-0">
|
||||
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
|
||||
</li>
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'access_users.js') }}"></script>
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'lists_access.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
||||
231
shopping_app/templates/admin/receipts.html
Normal file
@@ -0,0 +1,231 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Wszystkie paragony{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">
|
||||
📸 {% if id == 'all' %}Wszystkie paragony{% else %}Paragony dla listy #{{ id }}{% endif %}
|
||||
</h2>
|
||||
|
||||
<p class="text-white-50 small mt-1">
|
||||
{% if id == 'all' %}
|
||||
Rozmiar plików tej strony:
|
||||
{% else %}
|
||||
Rozmiar plików listy #{{ id }}:
|
||||
{% endif %}
|
||||
<strong>
|
||||
{% if page_filesize >= 1024*1024 %}
|
||||
{{ (page_filesize / 1024 / 1024) | round(2) }} MB
|
||||
{% else %}
|
||||
{{ (page_filesize / 1024) | round(1) }} kB
|
||||
{% endif %}
|
||||
</strong>
|
||||
{% if not (id != 'all' and (id|string).isdigit()) %}
|
||||
| Łącznie:
|
||||
<strong>
|
||||
{% if total_filesize >= 1024*1024 %}
|
||||
{{ (total_filesize / 1024 / 1024) | round(2) }} MB
|
||||
{% else %}
|
||||
{{ (total_filesize / 1024) | round(1) }} kB
|
||||
{% endif %}
|
||||
</strong>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
{% if id is string and id.isdigit() and id|int > 0 %}
|
||||
<a href="{{ url_for('admin_receipts', id='all') }}" class="btn btn-outline-light me-2">
|
||||
Pokaż wszystkie paragony
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('recalculate_filesizes_all') }}" class="btn btn-outline-light me-2">
|
||||
Przelicz rozmiary plików
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% for r in receipts %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark text-white h-100 shadow-sm border border-secondary">
|
||||
<a href="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}" class="glightbox"
|
||||
data-gallery="receipts" data-title="{{ r.filename }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}"
|
||||
class="card-img-top" style="object-fit: cover; height: 200px;"
|
||||
title="Token: {{ r.version_token or '0' }}">
|
||||
</a>
|
||||
|
||||
<div class="card-body text-center p-2 small">
|
||||
<div class="text-truncate fw-semibold" title="{{ r.filename }}">📄 {{ r.filename }}</div>
|
||||
<div>📅 {{ r.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</div>
|
||||
<div>👤 {{ r.uploaded_by_user.username if r.uploaded_by_user else "?" }}</div>
|
||||
<div>
|
||||
💾
|
||||
{% if r.filesize and r.filesize >= 1024 * 1024 %}
|
||||
{{ (r.filesize / 1024 / 1024) | round(2) }} MB
|
||||
{% elif r.filesize %}
|
||||
{{ (r.filesize / 1024) | round(1) }} kB
|
||||
{% else %}
|
||||
Brak danych
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="dropdown mt-2">
|
||||
<button class="btn btn-sm btn-outline-light dropdown-toggle w-100" type="button"
|
||||
data-bs-toggle="dropdown">
|
||||
⋮ Akcje
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark w-100 text-start">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('rotate_receipt', receipt_id=r.id) }}">🔄 Obróć o 90°</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#adminCropModal"
|
||||
data-img-src="{{ url_for('uploaded_file', filename=r.filename) }}?v={{ r.version_token or '0' }}"
|
||||
data-receipt-id="{{ r.id }}" data-crop-endpoint="{{ url_for('crop_receipt_admin') }}"
|
||||
data-version="{{ r.version_token or '0' }}">
|
||||
✂️ Przytnij
|
||||
</a>
|
||||
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('rename_receipt', receipt_id=r.id) }}">✏️ Zmień nazwę</a>
|
||||
</li>
|
||||
{% if not r.file_hash %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('generate_receipt_hash', receipt_id=r.id) }}">🔐 Generuj
|
||||
hash</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a class="dropdown-item text-danger" href="{{ url_for('delete_receipt', receipt_id=r.id) }}"
|
||||
onclick="return confirm('Na pewno usunąć plik {{ r.filename }}?');">🗑️ Usuń</a>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('edit_list', list_id=r.list_id) }}">📋 Edytuj listę #{{
|
||||
r.list_id }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not receipts %}
|
||||
<div class="alert alert-info text-center mt-4" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Nie wgrano żadnego paragonu
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if id == 'all' %}
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<form method="get" class="d-flex align-items-center">
|
||||
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
|
||||
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
|
||||
onchange="this.form.page.value = 1; this.form.submit();">
|
||||
<option value="25" {% if per_page==25 %}selected{% endif %}>25</option>
|
||||
<option value="50" {% if per_page==50 %}selected{% endif %}>50</option>
|
||||
<option value="100" {% if per_page==100 %}selected{% endif %}>100</option>
|
||||
</select>
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
</form>
|
||||
|
||||
<nav aria-label="Nawigacja stron">
|
||||
<ul class="pagination pagination-dark mb-0">
|
||||
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
|
||||
</li>
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{
|
||||
p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if orphan_files and request.path.endswith('/all') %}
|
||||
<hr class="my-4">
|
||||
<h4 class="mt-3 mb-2 text-warning">Znalezione nieprzypisane pliki ({{ orphan_files_count }})</h4>
|
||||
<div class="row g-3">
|
||||
{% for f in orphan_files %}
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card bg-dark border-warning text-warning h-100">
|
||||
<a href="{{ url_for('uploaded_file', filename=f) }}" class="glightbox" data-gallery="receipts"
|
||||
data-title="{{ f }}">
|
||||
<img src="{{ url_for('uploaded_file', filename=f) }}" class="card-img-top"
|
||||
style="object-fit: cover; height: 200px;">
|
||||
</a>
|
||||
<div class="card-body text-center">
|
||||
<p class="small mb-1 fw-bold">{{ f }}</p>
|
||||
<div class="alert alert-warning small py-1 mb-2">Brak powiązania z listą!</div>
|
||||
<a href="{{ url_for('delete_receipt', filename=f) }}" class="btn btn-sm btn-outline-light w-100 mb-2"
|
||||
onclick="return confirm('Na pewno usunąć WYŁĄCZNIE plik {{ f }} z dysku?');">
|
||||
🗑 Usuń plik z serwera
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal fade" id="adminCropModal" tabindex="-1" aria-labelledby="userCropModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">✂️ Przycinanie paragonu</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div style="position: relative; width: 100%; height: 75vh;">
|
||||
<img id="adminCropImage" style="max-width: 100%; max-height: 100%; display: block; margin: auto;">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">❌ Anuluj</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light" id="adminSaveCrop">💾 Zapisz</button>
|
||||
</div>
|
||||
<div id="adminCropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none">
|
||||
<div class="spinner-border text-light" role="status"></div>
|
||||
<div class="mt-2 text-light">⏳ Pracuję...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.CROP_CONFIG = {
|
||||
modalId: "adminCropModal",
|
||||
imageId: "adminCropImage",
|
||||
spinnerId: "adminCropLoading",
|
||||
saveBtnId: "adminSaveCrop",
|
||||
endpoint: "/admin/crop_receipt"
|
||||
};
|
||||
</script>
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_crop.js') }}"></script>
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'receipt_crop_logic.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
158
shopping_app/templates/admin/settings.html
Normal file
@@ -0,0 +1,158 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ustawienia{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">⚙️ Ustawienia</h2>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<form method="post" id="settings-form" data-unsaved-warning="true">
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-header border-0">
|
||||
<strong>🔎 OCR — słowa kluczowe i czułość</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-info mb-2">
|
||||
Dodaj lokalne frazy (CSV lub JSON), np.: <code>summe, gesamtbetrag, importe total</code>
|
||||
</p>
|
||||
<textarea
|
||||
class="form-control settings-ocr-textarea mb-3"
|
||||
name="ocr_keywords"
|
||||
rows="3"
|
||||
placeholder="suma, razem do zapłaty, total"
|
||||
>{{ current_ocr }}</textarea>
|
||||
|
||||
<label for="ocr_sensitivity" class="form-label d-flex align-items-center gap-2">
|
||||
Poziom czułości OCR
|
||||
<span id="ocr_sens_badge" class="badge rounded-pill sens-badge">Średni</span>
|
||||
<span id="ocr_sens_value" class="small">({{ ocr_sensitivity }})</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
class="form-range"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
name="ocr_sensitivity"
|
||||
id="ocr_sensitivity"
|
||||
value="{{ ocr_sensitivity }}"
|
||||
>
|
||||
<div class="small mt-1">
|
||||
<ul class="mb-2 ps-3">
|
||||
<li><strong>Zalecane:</strong> <code>5–7</code> (balans dokładności i stabilności).</li>
|
||||
<li><strong>Niskie (1–3):</strong> szybsze, mniejsza wykrywalność trudnych skanów.</li>
|
||||
<li><strong>Średnie (4–7):</strong> dobre na większość paragonów — <em>polecane</em>.</li>
|
||||
<li><strong>Wysokie (8–10):</strong> agresywne binaryzowanie — lepsze dla bladych skanów,
|
||||
ale większe ryzyko fałszywych trafień i wolniejsze działanie.</li>
|
||||
</ul>
|
||||
Tip: jeśli pojawiają się „dziwne” sumy — obniż o 1–2 poziomy.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-header border-0 d-flex align-items-center justify-content-between">
|
||||
<strong>🎨 Kolory kategorii</strong>
|
||||
<button type="button" class="btn btn-outline-light btn-sm" id="reset-all">🔄 Wyczyść nadpisania</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3" id="categories-grid">
|
||||
{% for c in categories %}
|
||||
{% set hex_override = overrides.get(c.id) %}
|
||||
{% set hex_auto = auto_colors[c.id] %}
|
||||
{% set hex_effective = effective_colors[c.id] %}
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="settings-category-card h-100">
|
||||
<div class="settings-category-header mb-2">
|
||||
<label class="form-label d-block mb-0 settings-category-name" for="color_{{ c.id }}">{{ c.name }}</label>
|
||||
<span class="badge settings-override-badge {{ 'text-bg-info' if hex_override else 'text-bg-secondary' }}" data-role="override-status">
|
||||
{{ 'Nadpisany' if hex_override else 'Domyślny' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="override_enabled_{{ c.id }}" value="{{ '1' if hex_override else '0' }}" class="override-enabled">
|
||||
|
||||
<div class="settings-color-controls">
|
||||
<input
|
||||
type="color"
|
||||
class="form-control form-control-color category-color"
|
||||
id="color_{{ c.id }}"
|
||||
name="color_{{ c.id }}"
|
||||
value="{{ hex_effective }}"
|
||||
data-auto="{{ hex_auto }}"
|
||||
data-effective="{{ hex_effective }}"
|
||||
data-has-override="{{ '1' if hex_override else '0' }}"
|
||||
aria-label="Kolor kategorii {{ c.name }}"
|
||||
>
|
||||
|
||||
<div class="settings-color-actions" role="group" aria-label="Akcje koloru">
|
||||
<button type="button"
|
||||
class="btn btn-outline-light btn-sm reset-one"
|
||||
data-target="color_{{ c.id }}">
|
||||
🔄 Wyczyść nadpisanie
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-light btn-sm use-default"
|
||||
data-target="color_{{ c.id }}">
|
||||
🎯 Ustaw kolor domyślny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-indicators mt-3">
|
||||
<div class="indicator">
|
||||
<span class="badge text-bg-dark me-2">Efektywny</span>
|
||||
<span class="bar" data-kind="effective" style="background-color: {{ hex_effective }};"></span>
|
||||
<span class="hex hex-effective ms-2">{{ hex_effective|upper }}</span>
|
||||
</div>
|
||||
<div class="indicator mt-1">
|
||||
<span class="badge text-bg-light me-2">Domyślny</span>
|
||||
<span class="bar" data-kind="auto" style="background-color: {{ hex_auto }};"></span>
|
||||
<span class="hex hex-auto ms-2">{{ hex_auto|upper }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-4">
|
||||
<div class="card-header border-0">
|
||||
<strong>🔐 Bezpieczeństwo</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label for="max_login_attempts" class="form-label">Limit błędnych logowań (hasło główne)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
name="max_login_attempts"
|
||||
id="max_login_attempts"
|
||||
min="1"
|
||||
max="20"
|
||||
value="{{ max_login_attempts }}"
|
||||
>
|
||||
<div class="form-text text-muted">
|
||||
Po przekroczeniu limitu IP zostaje tymczasowo zablokowane.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 d-flex">
|
||||
<div class="btn-group" role="group" aria-label="Akcje ustawień">
|
||||
<button type="submit" class="btn btn-outline-light">💾 Zapisz</button>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-light">❌ Anuluj</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<link rel="stylesheet" href="{{ static_asset_url('static_bp.serve_css', 'admin_settings.css') }}">
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'admin_settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
64
shopping_app/templates/admin/templates.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Szablony list{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4 gap-2">
|
||||
<div>
|
||||
<h2 class="mb-2">🧩 Szablony list</h2>
|
||||
<p class="text-secondary mb-0">Szablony są niezależne od zwykłych list i mogą służyć do szybkiego tworzenia nowych list.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'admin/_nav.html' %}
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="card bg-dark text-white mb-4"><div class="card-body">
|
||||
<h5 class="mb-3">➕ Nowy szablon ręcznie</h5>
|
||||
<form method="post" class="stack-form">
|
||||
<input type="hidden" name="action" value="create_manual">
|
||||
<div class="mb-3"><label class="form-label">Nazwa</label><input type="text" name="name" class="form-control" required></div>
|
||||
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
|
||||
<div class="mb-3"><label class="form-label">Produkty</label><textarea name="items_text" class="form-control" rows="8" placeholder="Mleko x2 Chleb Jajka x10"></textarea><div class="form-text">Każdy produkt w osobnej linii. Ilość opcjonalnie po „x”.</div></div>
|
||||
<button type="submit" class="btn btn-success w-100">Utwórz szablon</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="card bg-dark text-white"><div class="card-body">
|
||||
<h5 class="mb-3">📋 Utwórz z istniejącej listy</h5>
|
||||
<form method="post" class="stack-form">
|
||||
<input type="hidden" name="action" value="create_from_list">
|
||||
<div class="mb-3"><label class="form-label">Lista źródłowa</label><select name="source_list_id" class="form-select" required>{% for l in source_lists %}<option value="{{ l.id }}">#{{ l.id }} — {{ l.title }}</option>{% endfor %}</select></div>
|
||||
<div class="mb-3"><label class="form-label">Nazwa szablonu</label><input type="text" name="template_name" class="form-control"></div>
|
||||
<div class="mb-3"><label class="form-label">Opis</label><textarea name="description" class="form-control" rows="2"></textarea></div>
|
||||
<button type="submit" class="btn btn-outline-light w-100">Utwórz z listy</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card bg-dark text-white"><div class="card-body">
|
||||
<div class="admin-page-head mb-3"><h5 class="mb-0">Wszystkie szablony użytkowników</h5><span class="badge rounded-pill bg-secondary">{{ templates|length }} szt.</span></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle keep-horizontal">
|
||||
<thead><tr><th>Nazwa</th><th>Produkty</th><th>Status</th><th>Autor</th><th>Akcje</th></tr></thead>
|
||||
<tbody>
|
||||
{% for template in templates %}
|
||||
<tr>
|
||||
<td><div class="fw-semibold">{{ template.name }}</div><div class="small text-secondary">{{ template.description or 'Bez opisu' }}</div></td>
|
||||
<td>{{ template.items|length }}</td>
|
||||
<td>{% if template.is_active %}<span class="badge rounded-pill bg-success">Aktywny</span>{% else %}<span class="badge rounded-pill bg-secondary">Wyłączony</span>{% endif %}</td>
|
||||
<td>{{ template.creator.username if template.creator else '—' }}</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form method="post"><input type="hidden" name="action" value="instantiate"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-primary" type="submit">➕ Utwórz listę</button></form>
|
||||
<form method="post"><input type="hidden" name="action" value="toggle"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-warning" type="submit">{% if template.is_active %}⏸ Wyłącz{% else %}▶ Włącz{% endif %}</button></form>
|
||||
<form method="post" onsubmit="return confirm('Usunąć szablon?')"><input type="hidden" name="action" value="delete"><input type="hidden" name="template_id" value="{{ template.id }}"><button class="btn btn-sm btn-outline-danger" type="submit">🗑 Usuń</button></form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-secondary py-4">Brak szablonów użytkowników.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
128
shopping_app/templates/admin/user_management.html
Normal file
@@ -0,0 +1,128 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Zarządzanie użytkownikami{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">👥 Zarządzanie użytkownikami</h2>
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
|
||||
{% include 'admin/_nav.html' %}
|
||||
|
||||
<!-- Formularz dodawania nowego użytkownika -->
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">➕ Dodaj nowego użytkownika</h5>
|
||||
<form method="post" action="{{ url_for('add_user') }}">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label for="username" class="form-label text-white-50">Nazwa użytkownika</label>
|
||||
<input type="text" id="username" name="username"
|
||||
class="form-control bg-dark text-white border-secondary rounded" placeholder="np. jan" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="password" class="form-label text-white-50">Hasło</label>
|
||||
<div class="input-group ui-password-group">
|
||||
<input type="password" id="password" name="password"
|
||||
class="form-control bg-dark text-white border-secondary rounded" placeholder="min. 6 znaków" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-grid">
|
||||
<button type="submit" class="btn btn-outline-light">➕ Dodaj użytkownika</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<table class="table table-dark align-middle sortable keep-horizontal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Login</th>
|
||||
<th>Rola</th>
|
||||
<th>Listy</th>
|
||||
<th>Produkty</th>
|
||||
<th>Paragony</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in user_data %}
|
||||
{% set user = entry.user %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td class="fw-bold">{{ user.username }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge rounded-pill bg-primary">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge rounded-pill bg-secondary">Użytkownik</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.list_count }}</td>
|
||||
<td>{{ entry.item_count }}</td>
|
||||
<td>{{ entry.receipt_count }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-light me-1" data-bs-toggle="modal"
|
||||
data-bs-target="#resetPasswordModal" data-user-id="{{ user.id }}" data-username="{{ user.username }}">
|
||||
🔑 Ustaw hasło
|
||||
</button>
|
||||
{% if not user.is_admin %}
|
||||
<a href="/admin/promote_user/{{ user.id }}" class="btn btn-sm btn-outline-light">⬆️ Ustaw admina</a>
|
||||
{% else %}
|
||||
<a href="/admin/demote_user/{{ user.id }}" class="btn btn-sm btn-outline-light">⬇️ Usuń admina</a>
|
||||
{% endif %}
|
||||
{% if user.username == 'admin' %}
|
||||
<a class="btn btn-sm btn-outline-light me-1 disabled" aria-disabled="true" tabindex="-1"
|
||||
title="Nie można usunąć konta administratora-głównego.">
|
||||
🗑️ Usuń
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/delete_user/{{ user.id }}" class="btn btn-sm btn-outline-light me-1"
|
||||
onclick="return confirm('Czy na pewno chcesz usunąć użytkownika {{ user.username }}?\\n\\nWszystkie jego listy zostaną przeniesione na administratora.')">
|
||||
🗑️ Usuń
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal resetowania hasła -->
|
||||
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-labelledby="resetPasswordModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<form method="post" id="resetPasswordForm">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title" id="resetPasswordModalLabel">Ustaw hasło</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="resetUsernameLabel">Dla użytkownika: <strong></strong></p>
|
||||
<div class="input-group ui-password-group">
|
||||
<input type="password" name="password" placeholder="Nowe hasło"
|
||||
class="form-control bg-dark text-white border-secondary rounded" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light w-100">💾 Zapisz nowe hasło</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ static_asset_url('static_bp.serve_js', 'user_management.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||