Compare commits

353 Commits

Author SHA1 Message Date
c9f46d7547 ci: fix?
All checks were successful
ci/woodpecker/push/docs Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2025-01-19 07:12:09 +01:00
9f23f5768c ci: restructure
Some checks failed
ci/woodpecker/push/docs Pipeline failed
ci/woodpecker/push/test Pipeline failed
2025-01-19 07:07:53 +01:00
19210f90cd ci: try teests 2025-01-19 07:04:22 +01:00
462bb8f485 refactor: formatting 2025-01-18 21:43:37 +01:00
ea4d15b99a tests: Add test for index view 2025-01-18 21:43:00 +01:00
de30dfcb8b tests: Add test for unsubscribe 2025-01-18 21:39:45 +01:00
36a979954c feat: make sure owner also gets notified, add test 2025-01-18 18:53:55 +01:00
71ef17dc97 feat: Move pytest, coverage and model bakery to develop dependencies 2025-01-18 18:30:02 +01:00
206cd282e6 feat: Add coverage report instructions 2025-01-18 18:29:04 +01:00
e399346c3e feat: Add tests for subscription functionality 2025-01-18 16:17:22 +01:00
929c6dfff0 feat: Add registration button 2025-01-18 15:47:07 +01:00
841b57fea2 feat: Make sure subscribe leads to login flow 2025-01-18 15:25:27 +01:00
9e5446ff1d feat: Test adding a adoption as user 2025-01-18 15:22:08 +01:00
3b79809b8c docs: various 2025-01-18 09:07:33 +01:00
53e6db3655 refactor: Remove print 2025-01-14 07:31:00 +01:00
424f91e919 feat: Add a timestamp for when a notification is read 2025-01-14 07:30:36 +01:00
84ce5f54b2 refactor: Remove unnecessary print 2025-01-14 07:27:03 +01:00
a7e85212c0 feat: Add verbose names 2025-01-11 14:19:23 +01:00
f1b3b660ff feat: Add notification for unchecked ANs 2025-01-11 14:19:02 +01:00
26cb60c1c8 style: make registration flow use cards 2025-01-11 11:35:05 +01:00
69e58f1e0a docs: add changelog for 0.4.0 2025-01-11 11:18:22 +01:00
5c33ac3833 meta: bump version to 0.4.0 2025-01-11 11:03:07 +01:00
fccfd59ea3 refactor: change order for reading convenience 2025-01-11 11:02:42 +01:00
50897b6d35 fix: remove double , when searching for city 2025-01-09 23:39:23 +01:00
8edfe8c401 feat: Re-add warning if place not found 2025-01-09 23:35:07 +01:00
0d82dba414 fix: Remove debug code 2025-01-09 23:28:28 +01:00
2dc038dfef feat: Show search radius only if a search center is given and correct zoom level if no location is given 2025-01-09 23:27:33 +01:00
c46a943c7f feat: make map circle alot more transparent 2025-01-09 23:19:02 +01:00
9f3592e64b refactor: remove pulsing dot 2025-01-09 23:17:12 +01:00
bc1f4e7ab7 feat: construct search results better 2025-01-09 23:15:10 +01:00
a2ef91e89a feat: Handle missing name 2025-01-09 23:14:41 +01:00
91d740511d feat: Search language specific 2025-01-09 22:39:34 +01:00
c6af3e8d04 feat: Only show existing data 2025-01-09 19:26:33 +01:00
0c94049e21 fix: Fix bug for ANs that have not been checked for more that three months 2025-01-09 06:59:09 +01:00
29f1d2f0f2 feat: Fill search query with detailed information to make sure photon will get the same location 2025-01-09 06:41:54 +01:00
2578e96b32 feat: Remove legacy feedback to users 2025-01-09 06:34:50 +01:00
907ed583cd feat: Add tooltip to report link 2025-01-09 06:30:22 +01:00
da51007b77 feat: Add tooltip for unsubscribe 2025-01-09 06:22:01 +01:00
087f58c9ac feat: Style buttons as in other forms 2025-01-09 06:14:45 +01:00
860da7f06a feat: Use unified button layout 2025-01-09 06:12:21 +01:00
457bee1ede feat: Add verbose names to report 2025-01-09 06:10:44 +01:00
3b37b5f588 docs: label 2025-01-09 06:07:54 +01:00
6229f0f8a2 feat: Label ANs as active/inactive 2025-01-09 06:06:32 +01:00
b2a3d910d9 feat: Make geocoding API configurable 2025-01-08 17:57:48 +01:00
33848cbe15 feat: mention photon in readme 2025-01-08 09:29:01 +01:00
cc97fe32aa feat: add search-as-you-type functionality 2025-01-08 09:18:48 +01:00
4576ac68e0 feat: move search location not found error to the place where the location would have been shown 2025-01-07 15:59:22 +01:00
7c076e0bc3 feat: add logging and string representation 2025-01-07 15:04:43 +01:00
74f54c7b31 fix: make sure that search radius and pins are not cast to int 2025-01-07 15:04:23 +01:00
87777cd5a4 feat: add pin of map center 2025-01-07 14:56:23 +01:00
eee4cdf86b feat: Show location when searching 2025-01-07 14:37:02 +01:00
b2d5265f7e feat: Use photon for querying 2025-01-07 12:48:01 +01:00
d4af2d88b4 refactor: Remove unused function 2025-01-07 12:47:26 +01:00
8b4f5713e3 test: Use Location Proxy for test 2025-01-07 12:46:19 +01:00
4bff268537 fix: fix test 2025-01-07 12:45:51 +01:00
57da42e4bd feat: allow markdown in animal description 2025-01-07 09:19:41 +01:00
2864d27a7f refactor: typo 2025-01-06 10:21:00 +01:00
0a73b5099e refactor: Remove unnecessary div 2025-01-06 10:20:41 +01:00
e3fb981542 fix: Don't squash font into each other in card 2025-01-06 10:20:16 +01:00
5e80d75c91 feat: Add overview page of animal shelters 2025-01-06 09:02:07 +01:00
e3833b4505 feat: Show position of shelter on the map 2025-01-06 08:36:51 +01:00
ab837ee80e feat: Add contact information to rescue org 2025-01-05 22:55:26 +01:00
f6c1224dde feat: Group buttons as edit buttons 2025-01-05 21:54:55 +01:00
a78d671b6d feat(accessibility): use h1 only once per site 2025-01-05 21:35:29 +01:00
fb9c78d96a feat: Add species specific URL to allow faster checking if new animals exist in this rescue org 2025-01-05 21:04:27 +01:00
4ef9da953c feat: Add annotation for API 2025-01-05 20:22:21 +01:00
aefeffd63a feat: Add post method to create rescue orgs 2025-01-05 19:20:34 +01:00
81cc5cd53d feat: Add external source and object identifier 2025-01-05 19:20:05 +01:00
002dded0d5 feat: Add Spectacutlar API schema generation 2025-01-05 16:55:23 +01:00
ad6e2f4e17 fix: translate 2025-01-05 09:17:43 +01:00
160e7166f8 feat: reduce heading spacing, adjust cards 2025-01-04 11:30:36 +01:00
867319fe9a feat: space card containers 2025-01-04 11:24:57 +01:00
13b67c1248 feat: Add last checked to updatequeue 2025-01-04 09:52:22 +01:00
4c4cf4afea fix: remove debug message 2025-01-04 09:50:42 +01:00
5f742c60db fix: fix missing d
otherwise throws unsupported format character 'W' (0x57) at index 13
2025-01-04 09:50:32 +01:00
568874e6dd feat: Make edit buttons flex 2025-01-04 09:48:16 +01:00
561a30b7ab feat: Represent last checked more human-readable 2025-01-04 09:48:05 +01:00
a8c837e9f6 feat: Make sure heading fills complete line 2025-01-04 09:06:58 +01:00
a75cacea66 feat: Make table in adoption notice responsive 2025-01-03 22:04:19 +01:00
b1e092769f fix: Apply vertical align to all children 2025-01-03 20:19:55 +01:00
5a93a1678c refactor: remove unnecessary class 2025-01-03 20:19:34 +01:00
28772e1f74 feat: Restyle using a proper container to group elements and not just put them in the heading 2025-01-03 19:04:32 +01:00
1f3c3ecaef feat: Add top and bottom option to tooltip 2025-01-03 18:46:22 +01:00
ab1e6a94d1 feat: add tooltip to subscribe bell 2025-01-03 18:32:34 +01:00
299653b53b fix: syntax 2025-01-03 11:40:03 +01:00
fe9352e628 feat: Add tooltip explaining the meaning of the checkmark 2025-01-03 11:18:10 +01:00
9fec95bd2e feat: add trusted checkmark 2025-01-02 19:16:22 +01:00
8e7cdafee0 fix: deal with undefined 2025-01-02 11:14:34 +01:00
6e2a2a1d5e fix: use builtin function
https://docs.djangoproject.com/en/5.1/topics/auth/default/
2025-01-02 00:16:42 +01:00
5197875431 refactor: formatting 2025-01-01 23:52:54 +01:00
d05bd45cf4 feat: restyle search subscriptions 2025-01-01 23:52:44 +01:00
0afb2bb0ce feat: add list of search subscriptions to user profile 2025-01-01 23:29:23 +01:00
d17fcc1da2 feat: add updated_at and created_at to search subscription 2025-01-01 23:05:22 +01:00
c508bc2cd1 test: fix test after map was included 2025-01-01 22:56:01 +01:00
20872e547b fix: add turf to VC 2025-01-01 21:02:10 +01:00
25b748d2be fix: Style buttons 2025-01-01 20:58:50 +01:00
1536bb302a fix: handle missing radius 2025-01-01 20:55:50 +01:00
d4ef706734 feat: reorder search options 2025-01-01 20:55:35 +01:00
3bdce18e9e feat: make zoom level dependent on search radius 2025-01-01 20:26:59 +01:00
8b4488484d feat: show radius and center map 2025-01-01 20:14:07 +01:00
3881a4f3b4 feat: add map to search 2025-01-01 19:48:33 +01:00
2dbd908f4c feat: add method to search active ANs 2025-01-01 19:43:50 +01:00
9d0eed5915 test: Add e2e test for distance 2025-01-01 18:59:01 +01:00
ee12bb5286 feat: add debugging statements 2025-01-01 17:52:46 +01:00
5669c822b9 test: fix test search 2025-01-01 17:52:28 +01:00
c1c4af6571 feat: Add logging 2025-01-01 17:35:27 +01:00
164ba7def2 feat: Add test for adoption_notice_fits_search 2025-01-01 17:23:38 +01:00
7035b1642e feat: streamline search_from pattern 2025-01-01 17:22:26 +01:00
b6fc5c634f feat: add debugging messages 2025-01-01 17:21:09 +01:00
0dfbd614ab fix: remove deprecated search position 2025-01-01 17:20:44 +01:00
2730ff3f51 refactor: Create shared task for post-AN stuff 2025-01-01 14:35:40 +01:00
fef211b2d0 feat: Add logging 2025-01-01 14:34:38 +01:00
f2e2599561 fix: Make sure that subscribed search is only checked when user is authenticated 2025-01-01 09:47:07 +01:00
a9c0f628f7 refactor: remove print 2025-01-01 09:46:20 +01:00
e2adb20231 feat: re-add locate to ease use 2025-01-01 09:44:56 +01:00
e8b3bf6516 fix: fix general notify 2025-01-01 00:57:37 +01:00
3306f3e783 feat: Add notification for newly created ANs 2025-01-01 00:30:14 +01:00
b993621773 feat: add unsubscribe functionality 2024-12-31 16:25:18 +01:00
3816290eb7 fix: Location matching logic 2024-12-31 15:40:57 +01:00
399ecf73ad feat: use location proxy to make Location search interface more intuitive 2024-12-31 15:40:33 +01:00
8e2c0e857c feat: Show subscribe/unsubscribe button depending on the user having this search already subscribed 2024-12-31 13:48:44 +01:00
3c7dcb4c51 feat: Allow location to be null in SearchSubscription 2024-12-31 13:47:38 +01:00
9e1ec1711b fix: Make Search and Search subscription are not the same if Search is not localized 2024-12-31 13:38:06 +01:00
bae4ee3d22 feat: Do not show subscribe button when not yetsearched 2024-12-31 13:37:31 +01:00
280eb83056 feat: Add function to convert a search subscription to a search 2024-12-31 13:28:41 +01:00
fca5879aeb test: Add tests for search equality 2024-12-31 13:28:14 +01:00
373a44c9da fix: notify only subscribers where the AN fits 2024-12-31 13:27:35 +01:00
674645c65c feat: Add string representation of search 2024-12-31 13:26:38 +01:00
c2b3ff2395 refactor: Rename radius to streamline although it would be a better description 2024-12-31 13:25:43 +01:00
d6740eb302 test: adjust to reflect changed field name 2024-12-31 13:14:13 +01:00
35a54474b4 test: fix name 2024-12-31 12:55:46 +01:00
6723dad4bd test: add owner to test to prevent random owner generation 2024-12-31 12:55:13 +01:00
b51d04ffd1 feat: Use label in string representation 2024-12-31 12:14:04 +01:00
a965f26d48 fix: use Sex choices with all 2024-12-31 11:38:52 +01:00
364a6f32f4 refactor: Typo 2024-12-31 11:35:29 +01:00
533142461a feat: Add string representation of SearchSubscriptions 2024-12-31 10:20:38 +01:00
481635ac4e feat: Add contributing doc 2024-12-31 10:18:18 +01:00
be6c30cb33 feat: Add SearchSubscriptions to admin 2024-12-31 10:03:56 +01:00
a617137fb0 fix: form submit must have name subscribe_to_search to trigger 2024-12-31 10:03:39 +01:00
8299162a77 formatting 2024-12-26 20:27:01 +01:00
085162d802 feat: Add is_subscribed method for searches 2024-12-26 20:24:35 +01:00
27b7e47f18 feat: Add SearchSubscriptions 2024-12-26 20:24:10 +01:00
be97ac32fb refactor: Move search into class 2024-12-26 16:55:37 +01:00
9ea00655d4 refactor: Use integer choice for search 2024-12-24 10:02:08 +01:00
9fffbffdb7 feat: add FAQ section to about 2024-12-24 09:05:11 +01:00
44cf2936d1 ui: Make button more buttony 2024-12-24 09:01:13 +01:00
579f59580c feat: Redirect user to adoption notice after adding photo to an animal 2024-12-18 10:17:27 +01:00
241841bc9b feat: Redirect user to adoption notice after editing an animal 2024-12-18 10:15:00 +01:00
78a6440f63 feat: Re-add text decoration for accessibility 2024-12-17 23:02:30 +01:00
9d521b0129 feat: Set title on user page 2024-12-17 22:55:46 +01:00
39079c3c8e feat: Add fallback if first and lastname is not defined 2024-12-17 22:55:30 +01:00
999c1a81b8 feat: Restructure user page 2024-12-17 22:51:04 +01:00
5a4720c41c feat: Show "No notifications" message 2024-12-17 22:46:27 +01:00
858c6d4468 feat: Use real toggle 2024-12-17 22:46:01 +01:00
4b45b01e2a feat: Add toggle for e-mail notifications 2024-12-17 21:42:10 +01:00
d0060ecf5e feat: Fully define place_not_found 2024-12-17 20:14:34 +01:00
d1eeaafc42 feat: Also search for description, internal comment and location of rescue orgs
icontains is default and therefore ommitted
https://docs.djangoproject.com/en/5.1/ref/contrib/admin/#django.contrib.admin.ModelAdmin.search_fields
2024-12-17 20:14:14 +01:00
9b824bc326 feat: Automatically subscribe user that created AN to AN 2024-12-14 13:24:51 +01:00
44f05cbb7d feat: Notify all subscribers of a adoption 2024-12-14 13:03:00 +01:00
0e4e531414 feat: Add 404 deactivation to instance health check 2024-12-14 09:32:37 +01:00
6a7b3f19e9 feat: Link Adoption Notice on notification 2024-12-14 09:31:46 +01:00
ec9f5b305c feat: Create notifications for 404 deactivation 2024-12-14 09:31:06 +01:00
e858f61b3f feat: Translate Species 2024-12-14 08:41:00 +01:00
a04270718f feat: Make announcement collapsable 2024-12-14 08:28:37 +01:00
a4f895de81 feat: add admin for Base Notification 2024-12-12 06:40:11 +01:00
b2d0e783be feat: make sure report flag stays in its lane
very demure, very mindful
2024-12-11 22:45:45 +01:00
4f5022e140 feat: Use card concept for about site 2024-11-30 09:37:14 +01:00
5771968981 refactor: remove unnecessary imports 2024-11-30 09:31:14 +01:00
b63b87872b feat: Improve accessibility by using correct heading layer 2024-11-30 09:31:00 +01:00
1594b754cb docs: Fix api endpoint
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-25 18:36:33 +01:00
8ec27191b6 docs: Document API methods
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-25 16:27:42 +01:00
c1332ee1f0 feat: Add API methods for Animals, Images, Species andRescue orgs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-24 22:29:19 +01:00
f6240a7189 Merge branch 'ui_mobile' 2024-11-24 15:58:32 +01:00
7a02774a29 feat: make menu more spaced but remove border 2024-11-24 15:58:22 +01:00
8945fdc0f4 feat: Make header without radius so that menu and header don't have hole 2024-11-24 15:50:10 +01:00
9f0a18ad91 feat: Make sure main menu is shown in the middle when enabled 2024-11-24 14:21:52 +01:00
e7f26dd23a feat: integrate profile card seamlessly 2024-11-24 14:13:41 +01:00
fc5b1391df feat: align items, hide unnecessary for mobile 2024-11-24 12:46:46 +01:00
70bf8e2053 feat: re-add profile card 2024-11-24 12:46:30 +01:00
caf98ba60b docs: Add two endpoints
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-24 11:51:37 +01:00
d7e466050a docs: remove not implemented warning 2024-11-24 11:50:34 +01:00
34b707ef20 feat: add expandable navigation, sacrifice right header for now 2024-11-24 11:30:12 +01:00
064a9bf83a feat: Streamline serializer use, check trust level, add log 2024-11-23 14:50:26 +01:00
93070a3bcd refactor: formatting 2024-11-22 21:55:25 +01:00
23c35fe7dd refactor: Naming 2024-11-22 18:50:59 +01:00
d2542060a1 feat: Allow to bulk-activate ANs in the admin interface 2024-11-22 18:50:46 +01:00
89f74cb709 fix: Save date of last checked
Otherwise, ANs will get deactivated in the next night again
2024-11-22 18:50:23 +01:00
ec38012ecb test: fix test by setting date of last checked correctly 2024-11-22 18:49:19 +01:00
72d45a4f47 refactor: typo 2024-11-22 18:48:57 +01:00
8de5f162eb feat:Add notification when AN is set unchecked
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-22 11:57:13 +01:00
dc3859d589 test: fix search test 2024-11-22 11:56:11 +01:00
b4f52c7876 fix: Save last checked correctly 2024-11-22 07:15:50 +01:00
885622e581 feat: allow not searching for location 2024-11-21 23:07:27 +01:00
a42a3fa177 feat: allow search for sex 2024-11-21 22:51:15 +01:00
27541c6fb6 feat: add choice with all 2024-11-21 22:50:04 +01:00
14547ad621 fix: don't exchange set for new one 2024-11-21 22:49:00 +01:00
8d2d80c30e feat: Add intersex option for animals, fix bug 2024-11-21 20:37:38 +01:00
e6f5a42d15 feat: Add intersex option for animals
Intersex rats are rare but well documented.
2024-11-21 20:35:09 +01:00
052e42f76a feat: Use text choices for sex 2024-11-21 20:29:52 +01:00
3eb7dbe984 fix: Allow all notifications to be marked as read
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-20 23:37:55 +01:00
202dfe46c2 fix: For some reason this needs width now? 2024-11-20 23:34:25 +01:00
01da0f1e29 feat: add 403 page 2024-11-20 23:23:06 +01:00
8ccdf50bc5 refactor: Use new trust level class 2024-11-20 23:08:02 +01:00
d46ab8da6b feat: Show all notifications on profile 2024-11-20 23:03:02 +01:00
1dd53a87e9 feat: Notify admins of new user via notification framework #10 2024-11-20 23:02:41 +01:00
40bb2e54bd fix: Readjust trust level 2024-11-20 23:01:19 +01:00
433ad9d4b9 refactor: remove unnecessary print 2024-11-20 22:53:48 +01:00
231c27819d feat: Send e-mail notifications to user 2024-11-20 20:26:44 +01:00
890309564f feat: Add field for user to opt-out of e-mail notifications 2024-11-20 20:26:05 +01:00
e1e1f822c8 fix: Use correct view 2024-11-20 20:00:36 +01:00
7a788f4c90 fix: Allow users to perform actions on own profile 2024-11-20 20:00:27 +01:00
7efa626b8b feat: Migrate to new Integer choice field to allow nicer handling 2024-11-20 19:55:16 +01:00
08e20e1875 feat: Add basic user data export 2024-11-18 23:01:27 +01:00
f1c79a5f94 feat: UI improvements for user profile 2024-11-18 22:58:32 +01:00
5dd1991af8 feat: Restructure view of own profile, add token authorization for API 2024-11-18 22:41:12 +01:00
c0edef51bd feat: add explanation for signup reason 2024-11-18 18:34:12 +01:00
cb703e79ae feat: add fancy rat 2024-11-18 18:33:53 +01:00
87066b0cea feat: add signup-reason 2024-11-14 21:54:32 +01:00
c4976c4b34 feat: Upgrade django-registration to 5.1 2024-11-14 21:54:17 +01:00
ee46ff9cda fix: typo 2024-11-14 21:53:24 +01:00
d4f27e8f2f feat: Allow to set organization when creating adoption notice 2024-11-14 21:11:34 +01:00
4a6584370e feat: re-add understrike to buttons 2024-11-14 21:11:12 +01:00
82d3f95c99 feat: add understrike to improve accessibility 2024-11-14 21:00:52 +01:00
dce3d89c7e feat: rename comment to internal comment 2024-11-14 19:31:42 +01:00
5520590145 feat: Add link to rescue org 2024-11-14 19:29:41 +01:00
efabebfdbf feat: Add ANs to rescue organization 2024-11-14 19:27:32 +01:00
6c52246bb7 feat: Add detail view for organizations 2024-11-14 19:16:47 +01:00
2c11f7c385 feat: Add description for organizations 2024-11-14 19:01:24 +01:00
9ee0bd8e30 feat: Add rescue to detail view 2024-11-14 18:49:28 +01:00
1955476d24 feat: Improve activation e-mail format 2024-11-14 18:32:08 +01:00
05178da029 feat: Add captcha to registration 2024-11-14 18:30:51 +01:00
7a80cf8df1 refactor: remove unused 2024-11-14 18:29:56 +01:00
db94ec41ed feat: Add organization affiliation to user 2024-11-14 18:28:55 +01:00
5582538a70 fix: Pin django registration version, otherwise causes reverse error 2024-11-14 18:28:02 +01:00
7aa364fc38 refactor: remove unnecessary print 2024-11-14 07:10:49 +01:00
96ce5963fe feat: add phone number 2024-11-14 07:10:27 +01:00
bf54bc5d51 test: fix test by setting trust level of admin user as admin
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-11-12 22:44:00 +01:00
93ae172431 test: fix name of AN 2024-11-12 22:39:07 +01:00
03d40a5092 test: Add test for creating a standard user 2024-11-12 22:38:31 +01:00
993f8f9cd2 feat: Allow export of users as CSV 2024-11-12 17:20:07 +01:00
8efc0aad21 feat: Show ANs in admin view of user 2024-11-12 17:19:30 +01:00
3a6e7f5344 feat: Add customizable external site warning to 2024-11-12 17:18:20 +01:00
dac9661d51 feat: Add comments to admin 2024-11-12 13:17:53 +01:00
b9bfa8e359 feat: Add mail to admins when new user registers 2024-11-12 13:12:45 +01:00
d07589464c test: Add basic form test 2024-11-11 13:01:08 +01:00
1880da5151 refactor: blank line 2024-11-11 13:00:57 +01:00
4e953c83ea feat: Add comment field to RescueOrg
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-09 10:09:46 +01:00
2212df4729 feat: add sex coding 2024-11-09 10:03:04 +01:00
98d67381c6 feat: mark location with fa icon 2024-11-08 08:49:27 +01:00
e02672c2bb feat: Make sure flag stays in line 2024-11-08 08:11:22 +01:00
c3dd9faa85 feat: Use truncated location 2024-11-08 07:42:34 +01:00
9f977e35c2 feat: Use minimal view for listing ANs on index 2024-11-08 07:35:37 +01:00
3269d5a39a feat: add search for text 2024-11-07 23:16:47 +01:00
d96a44bbdd feat: search case-insensitive 2024-11-07 22:13:55 +01:00
2641b2e7bf feat: add search in admin for ANs 2024-11-07 22:01:21 +01:00
50c1a4f2c6 fix: save timestamp 2024-11-07 21:58:12 +01:00
573630f9ee feat: allow filtering and searching rescue orgs 2024-11-07 21:57:58 +01:00
1a09b7859f feat: add e-mail to rescue organization 2024-11-07 21:41:33 +01:00
70b3ae4bbc docs: fix copyright
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-07 21:23:37 +01:00
5eaafe7646 docs: add link 2024-11-07 21:23:28 +01:00
5781b49c7c docs: translate
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-07 21:20:21 +01:00
e2f516d409 feat: make external site nicer 2024-11-07 13:02:30 +01:00
ca8996fff6 docs: Expand moderationskonzept
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-07 12:33:49 +01:00
eb734d2716 feat: Add about_us text 2024-11-07 07:58:17 +01:00
655e304c6c fix 2024-11-06 23:47:14 +01:00
8e34ed440e test: Test for correct behaviour for further information = None 2024-11-06 23:32:50 +01:00
0c7080f005 feat: Add admin tasks 2024-11-06 23:32:37 +01:00
0b93b5eccb fix: Correct behaviour for further information = None 2024-11-06 23:32:01 +01:00
f1d9f7ad22 test: Add test for deactivate_404_adoption_notices 2024-11-06 08:03:45 +01:00
4e71ac7866 fix: Use field value not html representation 2024-11-06 08:03:32 +01:00
1d0a42a7e1 feat: Add migration for log change 2024-11-06 08:02:59 +01:00
d384e75746 refactor: spacing 2024-11-06 07:52:38 +01:00
70154abd37 feat: Add function to deactivate AN when link returns 404 2024-11-05 07:47:31 +01:00
ab3437e61d test: test function to check if site is up 2024-11-05 07:38:13 +01:00
0ccbb18411 feat: Add function to check if site is up 2024-11-05 07:37:52 +01:00
e6f12ce5b1 test: fix assertion method (not a request return but a list is returned) 2024-11-05 07:35:58 +01:00
6325de17d9 feat: Add updated_at and created at where it makes sense 2024-11-03 21:08:15 +01:00
b9d6293546 test:Add tests for deactivation tasks 2024-11-03 20:36:13 +01:00
dbe52e4884 fix: Deactivate ANs OLDER than three weeks, not newer 2024-11-03 20:35:41 +01:00
3c286d84d8 feat: nicer AN name 2024-11-03 16:37:35 +01:00
227fa4d5a8 refactor: rename 2024-11-02 09:43:02 +01:00
d47f181e1d feat: Make updatequeue parted 2024-11-02 09:42:39 +01:00
272046142e refactor: Move card of AN check to partial 2024-10-30 17:57:58 +01:00
5c18832961 test: Test updatequeue further 2024-10-30 11:18:24 +01:00
d59cc0034a refactor: Adjust status changes 2024-10-30 11:17:57 +01:00
64024be833 feat: Add test case for setting AN as checked 2024-10-29 18:49:02 +01:00
5ef20bdce0 fix: Make sure AN is active 2024-10-29 18:04:42 +01:00
7ddd7b0c0c feat: Test distance calculation 2024-10-29 17:52:07 +01:00
cbd8700917 feat: Test search for location 2024-10-29 17:51:55 +01:00
6eb2f5000f feat: Use Stadt instead of postcode 2024-10-29 17:51:28 +01:00
1cd70228b9 fix: Use timezone data not native datetime 2024-10-29 17:50:53 +01:00
23d8e85031 fix: Use timezone data not native datetime 2024-10-29 17:50:18 +01:00
4fb92d8215 refactor: Remove unused import 2024-10-29 17:49:54 +01:00
6dfc92bf15 fix: Correct import 2024-10-29 17:49:39 +01:00
2015f8b332 feat: Use new shortcuts when creating ANs 2024-10-29 06:53:34 +01:00
66a0b42718 feat: Add basic tests for search 2024-10-29 06:53:02 +01:00
efecfc910d feat: Add shortcut for setting status 2024-10-29 06:52:43 +01:00
96bc44c508 feat: add pytest as development dependency 2024-10-27 17:55:21 +01:00
a2c8f469a7 refactor: format 2024-10-27 17:54:57 +01:00
a98b428614 refactor: delete unused tests.py 2024-10-27 17:54:45 +01:00
dfede77e98 docs: Add moderationskonzept 2024-10-27 17:54:30 +01:00
6702211c05 docs: various refactoring
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-27 06:43:10 +01:00
f97e682640 docs: Registrierungen 2024-10-26 08:55:31 +02:00
c1e3248cc8 feat: show all ANs to be checked, not only active ones 2024-10-26 08:52:59 +02:00
0e67b777b5 fix: adoption form 2024-10-26 08:52:38 +02:00
0435c427b3 fix: Detail Animal form 2024-10-26 08:52:23 +02:00
be2df6970a fix: search template 2024-10-26 07:43:04 +02:00
1f5e7856b1 fix: url name 2024-10-19 20:38:24 +02:00
793de1ec64 fix: Add migration 2024-10-19 20:37:32 +02:00
6844e771b5 feat: Add timestamps to instance health check 2024-10-19 19:46:11 +02:00
1282b6b201 feat: Log tasks 2024-10-19 19:45:42 +02:00
975de1a230 fix: make sure anonymous users can look at ans 2024-10-19 17:45:26 +02:00
fd3478600f feat: exchange docs picture
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-14 21:46:50 +02:00
4d2991ba2f feat: Add basic logs 2024-10-10 23:21:07 +02:00
5aaaf57dd4 docs: document healthchecks 2024-10-10 23:19:34 +02:00
766b19e7c2 fix: comment action 2024-10-10 23:19:17 +02:00
f660a6b49a fix: varname 2024-10-10 22:26:28 +02:00
ab0c1a5c46 refactor: make healthchekscheck hourly 2024-10-10 22:24:55 +02:00
74a6b5f2aa feat: Add healthcheck 2024-10-10 18:35:22 +02:00
e38234b736 docs: Add basics on Vermittlungen
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-10 17:10:17 +02:00
ce38002676 chore: bump version 2024-10-10 17:09:33 +02:00
314cdfdd7c fix: Add missing celery file 2024-10-10 17:07:58 +02:00
4504a18f60 feat: add celery task to deactivate unchecked 2024-10-10 17:07:11 +02:00
df41028e99 feat: Add unchecked AN cleanup to health check 2024-10-10 17:06:50 +02:00
f404cfa0a3 feat: Add unchecked as AN status 2024-10-10 17:06:29 +02:00
72dedb6b0c fix: Build psycopg2 from source, pin python minor version 2024-10-10 14:33:18 +02:00
17468097ec fix: Add tag 2024-10-10 07:39:53 +02:00
28331f105a feat: Use celery for location queries 2024-10-10 07:39:44 +02:00
39893c2185 feat: Add basic celery config 2024-10-09 21:54:31 +02:00
ab2b91735e feat: Set title per page 2024-10-09 20:46:50 +02:00
1b9574cca9 refactor: Reorder 2024-10-05 13:26:32 +02:00
0d52101f22 feat: Add basic redirect service 2024-10-05 11:22:10 +02:00
96c0c1218f refactor: identation 2024-10-05 11:05:50 +02:00
864c76bc21 feat: Add domain customtag 2024-10-05 11:05:33 +02:00
c3646e6334 fix: typo 2024-10-05 11:02:37 +02:00
83a219df0c refactor: Add staticmethod to get a number of texts 2024-10-05 11:02:26 +02:00
e6428965c4 fix: Use pre-built dockerfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-10-04 16:50:16 +02:00
7f31c58abf fix attempt
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-10-04 16:23:52 +02:00
1c3d2c7cf5 fix attempt
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-10-04 16:20:08 +02:00
90489e01b0 fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-10-04 16:13:19 +02:00
ed8ccd96f4 fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-10-04 16:10:04 +02:00
2d690d4536 ci: Adjust Dockerfile
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-10-04 11:29:09 +02:00
4ca5ed733c ci: Secret config
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-10-04 11:08:49 +02:00
61cee697e4 ci: Add docs ci
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
2024-10-04 09:20:34 +02:00
8798f38f2a docs: Add initial documentation 2024-10-03 09:03:28 +02:00
cf061e02d2 feat: Add updatequeue template 2024-10-03 08:46:12 +02:00
258d9827fd docs: Adjust homepage and repo 2024-10-03 08:45:55 +02:00
cc95a2832d feat: Add basic API 2024-10-03 08:45:42 +02:00
73a6abef18 feat: Add date last checked for adoption notice detail view 2024-09-30 15:59:20 +02:00
0fcc0e5d11 feat: add docu 2024-09-30 15:58:51 +02:00
f5e7fb1a12 fix: add missing migrations 2024-09-30 15:58:34 +02:00
d9232a1095 feat: Add last checked to ANs and add updatequeue 2024-09-30 15:22:19 +02:00
022bf577d4 feat: Allow (un)subscribing without commenting 2024-09-29 23:35:54 +02:00
147 changed files with 6028 additions and 671 deletions

4
.coveragerc Normal file
View File

@@ -0,0 +1,4 @@
[run]
omit =
*/migrations/*
*/tests/*

29
.woodpecker/docs.yml Normal file
View File

@@ -0,0 +1,29 @@
---
steps:
build:
image: moanos/sphinx-rtd
commands:
- cd docs && make html
when:
event: [ tag, push ]
deploy:
image: appleboy/drone-scp
settings:
strip_components: 3
host:
from_secret: host
username:
from_secret: ssh_user
target:
from_secret: path
source: docs/_build/html/
key:
from_secret: ssh_key
when:
event: [ tag, push ]

14
.woodpecker/test.yml Normal file
View File

@@ -0,0 +1,14 @@
---
steps:
test:
image: python
commands:
- python -m pip install '.[develop]'
- coverage run --source='.' src/manage.py test src && coverage html
- coverage html
- cat htmlcov/index.html
when:
event: [tag, push]

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
## Version 0.4.0
Version 0.4.0 has added support for search-as-you-type when searching for animals to adopt. Furthermore, the display of
maps in the search has been majorly improved.
Photon has been added as geocoding source option which allows to use this functionality.
Further improvements include the representation of rescue organizations and tooltips.
One of the biggest features is the addition of search subscriptions. These allow you to not only
search for currently active adoption notices but to subscribe to that search so that you get notified if there are new
rats in your search area in the future.
For developers the new API documentation might come in handy, it can be found at
[/api/schema/swagger-ui/](https://notfellchen.org/api/schema/swagger-ui/)

View File

@@ -1,10 +1,12 @@
FROM python:3-slim
FROM python:3.11-slim
# Use 3.11 to avoid django.core.exceptions.ImproperlyConfigured: Error loading psycopg2 or psycopg module
MAINTAINER Julian-Samuel Gebühr
ENV DOCKER_BUILD=true
RUN apt update
RUN apt install gettext -y
RUN apt install libpq-dev gcc -y
COPY . /app
WORKDIR /app
RUN mkdir /app/data

View File

@@ -44,14 +44,16 @@ nf query_location <query>
There is a system for customizing texts in Notfellchen. Not every change of a tet should mean an update of the software. But this should also not become a CMS.
Therefore, a solution is used where a number of predefined texts per site are supported. These markdown texts will then be included in the site, if defined.
| Textcode | Location |
|---------------------|----------|
| `how_to` | Index |
| `introduction` | Index |
| `privacy_statement` | About |
| `terms_of_service` | About |
| `imprint` | About |
| Any rule | About |
| Textcode | Location |
|-------------------------|-----------------------|
| `how_to` | Index |
| `introduction` | Index |
| `privacy_statement` | About |
| `terms_of_service` | About |
| `imprint` | About |
| `about_us` | About |
| `external_site_warning` | External Site Warning |
| Any rule | About |
# Developer Notes
@@ -75,20 +77,36 @@ docker push moanos/notfellchen:latest
docker run -p8000:7345 moanos/notfellchen:latest
```
## Testing
Tests can be run with
```zsh
nf test src
```
If you want to report on code coverage run
```zsh
coverage run --source='.' src/manage.py test src
```
and
```
coverage report
```
## Geocoding
Geocoding services (search map data by name, address or postcode) are provided via the
[Nominatim](https://nominatim.org/) API, powered by [OpenStreetMap](https://openstreetmap.org) data. Notfellchen uses
a selfhosted Nominatim instance to avoid overburdening the publicly hosted instance. Due to ressource constraints
geocoding is only supported for Germany right now.
ToDos
* [ ] Implement a report that shows the number of location strings that could not be converted into a location
* [x] Add a management command to re-query location strings to fill location
either [Nominatim](https://nominatim.org/) or [photon](https://github.com/komoot/photon) API, powered by [OpenStreetMap](https://openstreetmap.org) data.
Notfellchen uses a selfhosted Photon instance to avoid overburdening the publicly hosted instance.
## Maps
The map on the main homepage is powered by [Versatiles](https://versatiles.org), and rendered using [Maplibre](https://maplibre.org/).
The Versatiles server is self-hosted and does not send data to third parties.
## Translation
@@ -106,3 +124,37 @@ Use a program like `gtranslator` or `poedit` to start translations
| Edit adoption notice | User that created, Moderator, Admin |
| Edit animal | User that created, Moderator, Admin |
| Add animal/photo to adoption notice | User that created, Moderator, Admin |
# Celery and KeyDB
Start KeyDB docker container
```zsh
docker run -d --name keydb -p 6379:6379 eqalpha/keydb
```
Start worker
```zsh
celery -A notfellchen.celery worker
```
Start beat
```zsh
celery -A notfellchen.celery beat
```
# Contributing
This project is currently solely developed by me, moanos. I'd like that to change and will be very happy for contributions
and shared responsibilities. Some ideas where you can look for contributing first
* CSS structure: It's a hot mess right now, and I'm happy it somehow works. As you might see, there is much room for improvement. Refactoring this and streamlining the look across the app would be amazing.
* Docker: If you know how to build a docker container that is a) smaller or b) utilizes staged builds this would be amazing. Any improvement welcome
* Testing: Writing tests is always welcome, and it's likely you discover a few bugs
I'm also very happy for all other contributions. Before you do large refactoring efforts or features, best write a short
issue for it before you spend a lot of work.
Send PRs either to [codeberg](https://codeberg.org/moanos/notfellchen) (preferred) or [Github](https://github.com/moan0s/notfellchen).
CI (currently only for dcumentation) runs via [git.hyteck.de](https://git.hyteck.de), you can also ask moanos for an account there.
Also welcome are new issues with suggestions or bugs and additions to the documentation.

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
_build/

101
docs/API/index.rst Normal file
View File

@@ -0,0 +1,101 @@
*****************
API Documentation
*****************
The Notfellchen API serves the purpose of supporting 3rd-person applications, whether you want to display data in a custom format or add data from other sources.
.. warning::
The current API is limited in it's functionality. I you miss a specific feature please contact the developers!
API Access
==========
Via browser
-----------
When a user is logged in, they can easily access the API in their browser, authenticated by their session.
The API endpoint can be found at http://notfellchen.org/api/adoption_notices
Via token
---------
All users are able to generate a token that allows them to use the API. This can be done in the user's profile.
An application can then send this token in the request header for authorization.
.. code-block::
$ curl -X GET http://notfellchen.org/api/adoption_notice -H 'Authorization: Token 49b39856955dc6e5cc04365498d4ad30ea3aed78'
.. warning::
Usage or creation of content still has to follow the terms of Notfellchen.org
Copyright of content is often held by rescue organizations, so you are not allowed to simply mirror content.
Talk to the Notfellchen-Team if you want develop such things.
Endpoints
---------
All Endpoints are documented at https://notfellchen.org/api/schema/swagger-ui/ or at https://notfellchen.org/api/schema/redoc/ if you prefer redoc.
The OpenAI schema can be downloaded at https://notfellchen.org/api/schema/
Examples are documented here.
Get Adoption Notices
++++++++++++++++++++
.. code-block::
curl --request GET \
--url https://notfellchen.org/api/adoption_notice \
--header 'Authorization: {{token}}'
Create Adoption Notice
++++++++++++++++++++++
.. code-block::
curl --request POST \
--url https://notfellchen.org/api/adoption_notice \
--header 'Authorization: {{token}}' \
--header 'content-type: multipart/form-data' \
--form name=TestAdoption1 \
--form searching_since=2024-11-19 \
--form 'description=Lorem ipsum **dolor sit** amet' \
--form further_information=https://notfellchen.org \
--form location_string=Berlin \
--form group_only=true
Add Animal to Adoption Notice
+++++++++++++++++++++++++++++
.. code-block::
curl --request POST \
--url https://notfellchen.org/api/animals/ \
--header 'Authorization: {{token}}' \
--header 'content-type: multipart/form-data' \
--form name=TestAnimal1 \
--form date_of_birth=2024-11-19 \
--form 'description=Lorem animal **dolor sit**.' \
--form sex=F \
--form species=1 \
--form adoption_notice=1
Add picture to Animal or Adoption Notice
++++++++++++++++++++++++++++++++++++++++
.. code-block::
curl -X POST https://notfellchen.org/api/images/ \
-H "Authorization: Token {{token}}" \
-F "image=@256-256-crop.jpg" \
-F "alt_text=Puppy enjoying the sunshine" \
-F "attach_to_type=animal" \
-F "attach_to=48
Species
+++++++
Getting available species is mainly important when creating animals
.. code-block::
curl --request GET \
--url https://notfellchen.org/api/species \
--header 'Authorization: {{token}}'

19
docs/Makefile Normal file
View File

@@ -0,0 +1,19 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

99
docs/README.md Normal file
View File

@@ -0,0 +1,99 @@
# QZT Dokumentation
![Deploy Status](https://woodpecker.hyteck.de/api/badges/103/status.svg)
# Quickstart
Create & activate a virtual environment to avoid cluttering your system
```zsh
python -m venv venv
source venv/bin/activate
```
Install dependencies
```zsh
pip install -r requirements.txt
```
And serve a local development version
```zsh
cd docs
sphinx-autobuild ./ ./_build/html
```
You can now access the documentation on [http://127.0.0.1:8000](http://127.0.0.1:8000). It will be rebuilt automatically upon file changes.
If you only want to build the static files once you can do `make html`.
## Docker
Build the docker image with
```bash
docker build . -t sphinx-qzt
```
and use it to build the documentation like this
```bash
docker run --rm -v ./docs:/docs sphinx-qzt make html
```
# QZT Dokumentation
# Quickstart
Create & activate a virtual environment to avoid cluttering your system
```zsh
python -m venv venv
source venv/bin/activate
```
Install dependencies
```zsh
pip install -r requirements.txt
```
And serve a local development version
```zsh
cd docs
sphinx-autobuild ./ ./_build/html
```
You can now access the documentation on <http://127.0.0.1:8000>. It will be rebuilt automatically upon file changes.
If you only want to build the static files once you can do `make html`.
## Docker
Build the docker image with
```bash
docker build . -t sphinx-rtd
```
and use it to build the documentation like this
```bash
docker run --rm -v ./docs:/docs sphinx-rtd make html
```
# CI
Woodpecker can be used to deploy the documentation to a server. Fo that purpose it builds a docker container that contains
sphinx and the read-the-docs theme, builds the documentation with that and pushes it to a server which will serve the static files.
| Key | Example | Description |
|-------------------|-------------------------------------------------------------|-------------------------------------------------------------|
| `host` | `dokumentation.notfellchen.org` | Hostename of the server where you want to deploy |
| `ssh_user` | `username` | User on the server |
| `ssh_key` | `-----BEGIN OPENSSH PRIVATE KEY-----` | The private SSH key of the user |
| `path` | `/static_sites/static-hyteck/dokumentation.notfellchen.org` | Path where to deploy the static files. |
| `docker_username` | `moanos` | Username authenticate to dockerhub to push the docker image |
| `docker_password` | `SUPERSECRET` | Password authenticate to dockerhub to push the docker image |

38
docs/admin/GDPR.rst Normal file
View File

@@ -0,0 +1,38 @@
GDPR
====
The GDPR provides the user with different rights regarding their data and Notfellchen tries to help you fulfill these requirements.
For this application there are different scenarios that are applicable.
Transparency and Modality
-------------------------
The user must be informed in a "in a concise, transparent, intelligible and easily accessible form,
using clear and plain language". This is currently up to you, to provide an imprint with such information.
Information and Access
----------------------
Article 15 gives the user the right to access their personal data and information about how this personal data is being
processed, specifically the purpose of the processing (Article 15(1)(a)), with whom the data is shared
(Article 15(1)(c)), and how it acquired the data (Article 15(1)(g)).
For Notfellchen this could be a short description::
Notfellchen processes your data to provide you with the functionality of the plattform, like creation of adoption notices.
Your data is not given to third parties. Your data was provided by you or added by staff/automatically if you consented to this.
It is also possible that there is data of you accessing resources of this program to prevent malicious activity and to improve the software in it's functionality.
The right to access the data can easily fulfilled with Notfellchen, a user can always request a copy of their data in their profile.
Rectification and erasure
-------------------------
Article 17 provides that the data subject has the right to request erasure of personal data related to them on any one of a number of grounds within 30 days.
This is currently not implemented by he software and has to be done by administrators manually.
.. warning::
All content on this website is intended for general information only, and should not be construed as legal advice.
Please seek a lawyer.

View File

@@ -0,0 +1,28 @@
# Global tags can be specified here in key="value" format.
[global_tags]
# Configuration for telegraf agent
[agent]
interval = "10s"
round_interval = true
metric_batch_size = 1000
metric_buffer_limit = 10000
collection_jitter = "0s"
flush_interval = "10s"
flush_jitter = "0s"
# Configuration for sending metrics to InfluxDB
[[outputs.influxdb]]
urls = ["http://:::8086"]
database = "telegraf"
skip_database_creation = true
username = 'telegraf'
password = 'yourpassword'
[[inputs.http]]
urls = ["https://notfellchen.org/metrics/"]
name_override = "notfellchen"
#Data from HTTP in JSON format
data_format = "json"

8
docs/admin/index.rst Normal file
View File

@@ -0,0 +1,8 @@
Administration
--------------
.. toctree::
:maxdepth: 2
:caption: Contents:
GDPR.rst
monitoring.rst

71
docs/admin/monitoring.rst Normal file
View File

@@ -0,0 +1,71 @@
Monitoring
==========
Notfellchen should, like every other software, be easy to monitor. Therefore a basic metrics are exposed to `https://notfellchen.org/metrics`.
The data is encoded in JSON format and is therefore suitable to bea read by humans and it is easy to use it as data source for further processing.
Exposed Metrics
---------------
.. code::
users: number of users (all roles combined)
staff: number of users with staff status
adoption_notices: number of adoption notices
adoption_notices_by_status: number of adoption notices by major status
adoption_notices_without_location: number of location notices that are not geocoded
Example workflow
----------------
To use the exposed metrics you will usually need a time series database and a visualization tool.
As time series database we will utilize InfluxDB, the visualization tool will be Grafana.
InfluxDB and Telegraf
^^^^^^^^^^^^^^^^^^^^^
First we install InfluxDB (e.g. with docker, be aware of the security risks!).
.. code::
# Pull the image
$ sudo docker pull influxdb
# Start influxdb
$ sudo docker run -d -p 8086:8086 -v influxdb:/var/lib/influxdb --name influxdb influxdb
# Start influxdb console
$ docker exec -it influxdb influx
Connected to http://localhost:8086 version 1.8.3
InfluxDB shell version: 1.8.3
> create database monitoring
> create user "telegraf" with password 'mypassword'
> grant all on monitoring to telegraf
.. note::
When creating the user telegraf check the double and single quotes for username an password.
Now install telegraf and configure `etc/telegraf/telegraf.conf`. Modify the domain and your password for the InfluxDB database.
.. literalinclude:: example.telegraf.conf
:linenos:
:language: python
Graphana
^^^^^^^^
Now we can simply use the InfluxDB as data source in Grafana and configure until you have
beautiful plots!
.. image:: monitoring_grafana.png
Healthchecks
------------
You can configure notfellchen to give a hourly ping to a healthchecks server. If this ping is not received, you will get notified and cna check why the celery jobs are no running.
Add the following to your `notfellchen.cfg` and adjust the URL to match your check.
.. code::
[monitoring]
healthchecks_url=https://health.example.org/ping/5fa7c9b2-753a-4cb3-bcc9-f982f5bc68e8

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

177
docs/conf.py Normal file
View File

@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
#
# Configuration file for the Sphinx documentation builder.
#
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'Notfellchen'
copyright = 'CC-BY-SA Julian-Samuel Gebühr'
author = 'Julian-Samuel Gebühr'
# The short X.Y version
version = ''
# The full version, including alpha/beta/rc tags
release = '0.2.0'
# -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.ifconfig',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The master toctree document.
master_doc = 'index'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = None
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
# html_sidebars = {}
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'notfellchen'
# -- Options for LaTeX output ------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'notfellchen.tex', 'Notfellchen Dokumentation',
'Julian-Samuel Gebühr', 'manual'),
]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'notfellchen', 'Notfellchen Dokumentation',
[author], 1)
]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'Notfellchen', 'Notfellchen Documentation',
author, 'Notfellchen', 'App für die Vermittlung von Tieren aus Tierschutz.',
'Miscellaneous'),
]
# -- Options for Epub output -------------------------------------------------
# Bibliographic Dublin Core info.
epub_title = project
# The unique identifier of the text. This can be a ISBN number
# or the project homepage.
#
# epub_identifier = ''
# A unique identification for the text.
#
# epub_uid = ''
# A list of files that should not be packed into the epub file.
epub_exclude_files = ['search.html']
# -- Extension configuration -------------------------------------------------

210
docs/dev/backup.rst Normal file
View File

@@ -0,0 +1,210 @@
Backup & Restore
****************
If you do no heavy modification of the code you should be fine with backing up :file:`/etc/notfellchen/` and the database.
Assuming you used a PostgreSQL database the following solution might help you with backups and restores.
Backup
++++++
The following code is a modification of `this script <https://wiki.postgresql.org/wiki/Automated_Backup_on_Linux>`_
licensed under the :ref:`postgresql_license`.
You will first need to create a backup configuration at :file:`/var/notfellchen/pg_backup.config`.
.. code-block::
##############################
## POSTGRESQL BACKUP CONFIG ##
##############################
# Optional system user to run backups as. If the user the script is running as doesn't match this
# the script terminates. Leave blank to skip check.
BACKUP_USER=notfellchen
# Optional hostname to adhere to pg_hba policies. Will default to "localhost" if none specified.
HOSTNAME=localhost
# Optional username to connect to database as. Will default to "postgres" if none specified.
USERNAME=notfellchen
# This dir will be created if it doesn't exist. This must be writable by the user the script is
# running as.
BACKUP_DIR=/var/notfellchen/backups/postgresql
# Enter database to backup
DATABSE=notfellchen
#### SETTINGS FOR ROTATED BACKUPS ####
# Which day to take the weekly backup from (1-7 = Monday-Sunday)
DAY_OF_WEEK_TO_KEEP=7
# Number of days to keep daily backups
DAYS_TO_KEEP=7
# How many weeks to keep weekly backups
WEEKS_TO_KEEP=5
######################################
And then add the script that will do the actual backup at :file:`/var/notfellchen/backup_rotate.sh`
.. code-block:: bash
#!/bin/bash
###########################
####### LOAD CONFIG #######
###########################
while [ $# -gt 0 ]; do
case $1 in
-c)
CONFIG_FILE_PATH="$2"
shift 2
;;
*)
${ECHO} "Unknown Option \"$1\"" 1>&2
exit 2
;;
esac
done
if [ -z $CONFIG_FILE_PATH ] ; then
SCRIPTPATH=$(cd ${0%/*} && pwd -P)
CONFIG_FILE_PATH="${SCRIPTPATH}/pg_backup.config"
fi
if [ ! -r ${CONFIG_FILE_PATH} ] ; then
echo "Could not load config file from ${CONFIG_FILE_PATH}" 1>&2
exit 1
fi
source "${CONFIG_FILE_PATH}"
###########################
#### PRE-BACKUP CHECKS ####
###########################
# Make sure we're running as the required backup user
if [ "$BACKUP_USER" != "" -a "$(id -un)" != "$BACKUP_USER" ] ; then
echo "This script must be run as $BACKUP_USER. Exiting." 1>&2
exit 1
fi
###########################
### INITIALISE DEFAULTS ###
###########################
if [ ! $HOSTNAME ]; then
HOSTNAME="localhost"
fi;
if [ ! $USERNAME ]; then
USERNAME="postgres"
fi;
###########################
#### START THE BACKUPS ####
###########################
function perform_backups()
{
SUFFIX=$1
FINAL_BACKUP_DIR=$BACKUP_DIR"`date +\%Y-\%m-\%d`$SUFFIX/"
echo "Making backup directory in $FINAL_BACKUP_DIR"
if ! mkdir -p $FINAL_BACKUP_DIR; then
echo "Cannot create backup directory in $FINAL_BACKUP_DIR. Go and fix it!" 1>&2
exit 1;
fi;
#######################
### GLOBALS BACKUPS ###
#######################
echo -e "\n\nPerforming backup"
echo -e "--------------------------------------------\n"
echo "Backup"
set -o pipefail
if ! pg_dump $DATABASE | gzip > $FINAL_BACKUP_DIR"$DATABASE".sql.gz.in_progress; then
echo "[!!ERROR!!] Failed to produce globals backup" 1>&2
else
mv $FINAL_BACKUP_DIR"$DATABASE".sql.gz.in_progress $FINAL_BACKUP_DIR"$DATABSE".sql.gz
fi
set +o pipefail
echo -e "\nAll database backups complete!"
}
# MONTHLY BACKUPS
DAY_OF_MONTH=`date +%d`
if [ $DAY_OF_MONTH -eq 1 ];
then
# Delete all expired monthly directories
find $BACKUP_DIR -maxdepth 1 -name "*-monthly" -exec rm -rf '{}' ';'
perform_backups "-monthly"
exit 0;
fi
# WEEKLY BACKUPS
DAY_OF_WEEK=`date +%u` #1-7 (Monday-Sunday)
EXPIRED_DAYS=`expr $((($WEEKS_TO_KEEP * 7) + 1))`
if [ $DAY_OF_WEEK = $DAY_OF_WEEK_TO_KEEP ];
then
# Delete all expired weekly directories
find $BACKUP_DIR -maxdepth 1 -mtime +$EXPIRED_DAYS -name "*-weekly" -exec rm -rf '{}' ';'
perform_backups "-weekly"
exit 0;
fi
# DAILY BACKUPS
# Delete daily backups 7 days old or more
find $BACKUP_DIR -maxdepth 1 -mtime +$DAYS_TO_KEEP -name "*-daily" -exec rm -rf '{}' ';'
perform_backups "-daily"
You should make the script executable test it and automate the execution with :program:`crontab`
.. code-block:: bash
$ chmod +x backup_rotate.sh
$ ./backup_rotate.sh
$ crontab -e
# enter the following to backup every day at 3am
0 3 * * * /var/notfellchen/backup_rotate.sh
Restore
+++++++
If you for any reason want to restore a backup you can use the following:
.. code-block:: bash
$ sudo systemctl stop notfellchen
$ pg_dump notfellchen > notfellchen_YYYY_MM_DD-hh_mm.psql # Make a backup for later analysis
$ dropdb notfellchen
$ cd /path/to/backup
$ gzip -d notfellchen.sql.gz
$ sudo -u postgres createdb -O notfellchen notfellchen
$ psql notfellchen < notfellchen.sql
$ systemctl restart notfellchen

39
docs/dev/contributing.rst Normal file
View File

@@ -0,0 +1,39 @@
Contributing
------------
Report a bug
^^^^^^^^^^^^
To report a bug, file an issue on `Github
<https://github.com/moan0s/notfellchen/issues>`_
Try to include the following information:
- The information needed to reproduce the problem
- What you would expect to happen
- What did actually happen
- Error messages
You are also invited to include:
- Screenshots
- Which browser you are using
- The URL of the site
- How urgent it is
- Any additional information you consider useful
Get involved!
^^^^^^^^^^^^^
To contribute simply clone the directory, make your changes and file a
pull request.
If you want to know what can be done, have a look at the current `Github
<https://github.com/moan0s/notfellchen/issues>`_.
Get in touch!
^^^^^^^^^^^^^
If you have questions, want to contribute or want to message me regarding something else
you can find contact information at https://hyteck.de/about/ or directly write
an `E-Mail <mailto:info@notfellchen.org>`_

261
docs/dev/deployment.rst Normal file
View File

@@ -0,0 +1,261 @@
.. highlight:: none
**********
Deployment
**********
There are different ways to deploy Notfellchen. We support an ansible+docker based deployment and manual installation.
Ansible deployment
==================
Notfellchen can be deployed with the `notfellchen-ansible-role <https://github.com/moan0s/ansible-role-notfellchen>`_ that is based on the
official Notfellchen docker image. This role will only install notfellchen itself. If you want a complete setup that includes a
database and a webserver with minimal configuration you can use the
`mash-playbook <https://github.com/mother-of-all-self-hosting/mash-playbook>`_ by following `it's documentation
on Notfellchen <https://github.com/mother-of-all-self-hosting/mash-playbook/blob/main/docs/services/notfellchen.md>`_.
Manual Deployment
=================
This guide describes the installation of a installation of Notfellchen from source. It is inspired by this great guide from
pretix_.
.. warning:: Even though this guide tries to make it as straightforward to run Notfellchen, it still requires some Linux experience to
get it right. If you're not feeling comfortable managing a Linux server, check out a managed service_.
This guide is tested on **Ubuntu20.04** but it should work very similar on other modern systemd based distributions.
Requirements
------------
Please set up the following systems beforehand, it will not be explained here in detail (but see these links for external
installation guides):
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
* A HTTP reverse proxy, e.g. `nginx`_ or Traefik to allow HTTPS connections
* A `PostgreSQL`_ database server
Also recommended is, that you use a firewall, although this is not a Notfellchen-specific recommendation. If you're new to
Linux and firewalls, it is recommended that you start with `ufw`_.
.. note:: Please, do not run Notfellchen without HTTPS encryption. You'll handle user data and thanks to `Let's Encrypt`_
SSL certificates can be obtained for free these days.
Unix user
---------
As we do not want to run notfellchen as root, we first create a new unprivileged user::
# adduser notfellchen --disabled-password --home /var/notfellchen
In this guide, all code lines prepended with a ``#`` symbol are commands that you need to execute on your server as
``root`` user (e.g. using ``sudo``); all lines prepended with a ``$`` symbol should be run by the unprivileged user.
Database
--------
Having the database server installed, we still need a database and a database user. We can create these with any kind
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
best compatibility. You can check this with the following command::
# sudo -u postgres psql -c 'SHOW SERVER_ENCODING'
For PostgreSQL database creation, we would do::
# sudo -u postgres createuser notfellchen
# sudo -u postgres createdb -O notfellchen notfellchen
# su notfellchen
$ psql
> ALTER USER notfellchen PASSWORD 'strong_password';
Package dependencies
--------------------
To build and run notfellchen, you will need the following debian packages::
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
python3-dev
Config file
-----------
We now create a config directory and config file for notfellchen::
# mkdir /etc/notfellchen
# touch /etc/notfellchen/notfellchen.cfg
# chown -R notfellchen:notfellchen /etc/notfellchen/
# chmod 0600 /etc/notfellchen/notfellchen.cfg
Fill the configuration file ``/etc/notfellchen/notfellchen.cfg`` with the following content (adjusted to your environment)::
[notfellchen]
instance_name=My library
url=https://notfellchen.example.com
[database]
backend=postgresql
name=notfellchen
user=notfellchen
[locations]
static=/var/notfellchen/static
[mail]
; See config file documentation for more options
; from=notfellchen@example.com
; host=127.0.0.1
; user=notfellchen
; password=foobar
; port=587
[security]
; See https://securitytxt.org/ for reference
;Contact=
;Expires=
;Encryption=
;Preferred-Languages=
;Scope=
;Policy=
Install notfellchen as package
------------------------
Now we will install notfellchen itself. The following steps are to be executed as the ``notfellchen`` user. Before we
actually install notfellchen, we will create a virtual environment to isolate the python packages from your global
python installation::
$ python3 -m venv /var/notfellchen/venv
$ source /var/notfellchen/venv/bin/activate
(venv)$ pip3 install -U pip setuptools wheel
We now clone and install notfellchen, its direct dependencies and gunicorn::
(venv)$ git clone https://github.com/moan0s/Notfellchen2
(venv)$ cd Notfellchen2/src/
(venv)$ pip3 install -r requirements.txt
(venv)$ pip3 install -e .
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
Finally, we compile static files and create the database structure::
(venv)$ ./manage.py collectstatic
(venv)$ ./manage.py migrate
(venv)$ django-admin compilemessages --ignore venv
Start notfellchen as a service
-------------------------
You should start notfellchen using systemd to automatically start it after a reboot. Create a file
named ``/etc/systemd/system/notfellchen-web.service`` with the following content::
[Unit]
Description=notfellchen web service
After=network.target
[Service]
User=notfellchen
Group=notfellchen
Environment="VIRTUAL_ENV=/var/notfellchen/venv"
Environment="PATH=/var/notfellchen/venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/var/notfellchen/venv/bin/gunicorn notfellchen.wsgi \
--name notfellchen --workers 5 \
--max-requests 1200 --max-requests-jitter 50 \
--log-level=info --bind=127.0.0.1:8345
WorkingDirectory=/var/notfellchen
Restart=on-failure
[Install]
WantedBy=multi-user.target
You can now run the following commands to enable and start the services::
# systemctl daemon-reload
# systemctl enable notfellchen-web
# systemctl start notfellchen-web
SSL
---
The following snippet is an example on how to configure a nginx proxy for notfellchen::
server {
listen 80;
listen [::]:80;
if ($scheme = http) {
return 301 https://$server_name$request_uri;
}
#
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/notfellchen.example.com/cert.pem;
ssl_certificate_key /etc/letsencrypt/live/notfellchen.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Set header
add_header X-Clacks-Overhead "GNU Terry Pratchett";
add_header Permissions-Policy interest-cohort=(); #Anti FLoC
add_header Referrer-Policy same-origin;
add_header X-Content-Type-Options nosniff;
server_name notfellchen.example.com;
location / {
proxy_pass http://localhost:8345;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
}
location /static/ {
alias /var/notfellchen/static/;
access_log off;
expires 365d;
add_header Cache-Control "public";
}
}
We recommend reading about setting `strong encryption settings`_ for your web server.
Next steps
----------
Yay, you are done! You should now be able to reach notfellchen at https://notfellchen.example.com/
Updates
-------
.. warning:: While we try hard not to break things, **please perform a backup before every upgrade**.
To upgrade to a new notfellchen release, pull the latest code changes and run the following commands::
$ source /var/notfellchen/venv/bin/activate
(venv)$ git pull
(venv)$ pg_dump notfellchen > notfellchen.psql
(venv)$ python manage.py migrate
(venv)$ django-admin compilemessages --ignore venv
# systemctl restart notfellchen-web
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
.. _Let's Encrypt: https://letsencrypt.org/
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
.. _strong encryption settings: https://mozilla.github.io/server-side-tls/ssl-config-generator/
.. _service: hyteck.de/services
.. _pretix: https://docs.pretix.eu/en/latest/admin/installation/manual_smallscale.html

13
docs/dev/index.rst Normal file
View File

@@ -0,0 +1,13 @@
********************************************
Installation, customization and contributing
********************************************
.. toctree::
:maxdepth: 2
:caption: Contents:
deployment.rst
contributing.rst
translation.rst
release.rst
backup.rst

View File

@@ -0,0 +1,14 @@
.. _postgresql_license:
PostgreSQL License
******************
.. code-block::
PostgreSQL Database Management System (formerly known as Postgres, then as Postgres95)
Portions Copyright (c) 1996-2008, The PostgreSQL Global Development Group
Portions Copyright (c) 1994, The Regents of the University of California
Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies.
IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.

40
docs/dev/release.rst Normal file
View File

@@ -0,0 +1,40 @@
Release
-------------
What qualifies as release?
^^^^^^^^^^^^^^^^^^^^^^^^^^
A new release should be announced when a significant number functions, bugfixes or other improvements to the software
is made. Notfellchen follows `Semantic Versioning <https://semver.org/>`_.
What should be done before a release?
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Tested basic functions
######################
Run :command:`nf test src`
Test upgrade on a copy of a production database
###############################################
.. WARNING::
You have to prevent e-mails from being sent, otherwise users could receive duplicate e-mails!
* Ensure correct migration if necessary
* Views correct?
Release
^^^^^^^
After testing everything you are good to go. Open the file :file:`src/setup.py` with a text editor
you can adjust the version number:
Do a final commit on this change, and tag the commit as release with appropriate version number.
.. code::
git tag -a v1.0.0 -m "Releasing version v1.0.0"
git push origin v1.0.0
Make sure the tag is visible on GitHub/Codeberg and celebrate 🥳

18
docs/dev/translation.rst Normal file
View File

@@ -0,0 +1,18 @@
Translation
===========
Translate HTML-files
____________________
First you have to add the text "{% load i18n %}" in every html file at the top.
Write the string in your html file between these two tags: {% translate "String" %}
Translate python-files
______________________
The underscore markes the string for translation. e.g. _("String")
Workflow
_________
- Generate the messages with the command: "django-admin makemessages -l de --ignore venv" de stands in this example for german
- Translate the strings in the file src/local/de/LC_MESSAGES/django.po
- Convert the strings for django with the command: "django-admin compilemessages --ignore venv"

18
docs/index.rst Normal file
View File

@@ -0,0 +1,18 @@
###################################
Notfellchen Plattform Dokumentation
###################################
.. toctree::
:maxdepth: 2
:caption: Contents:
user/index.rst
admin/index.rst
dev/index.rst
API/index.rst
.. image:: rtfm.png
:name: Ratte lesend
:alt: Zeichnung einer lesenden Ratte
:align: center

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
:end
popd

3
docs/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
sphinx
sphinx-rtd-theme
sphinx-autobuild

BIN
docs/rtfm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

BIN
docs/user/abonnieren.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,19 @@
Benachrichtigungen
==================
Ersteller*innen von Vermittlungen werden über neue Kommentare per Mail benachrichtigt, ebenso alle die die Vermittlung abonniert haben.
Jede Vermittlung kann abonniert werden. Dafür klickst du auf die Glocke neben dem Titel der Vermittlung.
.. image:: abonnieren.png
Auf der Website
+++++++++++++++
E-Mail
++++++
Mit während deiner :doc:`registrierung` gibst du eine E-Mail Addresse an.
Benachrichtigungen senden wir per Mail - du kannst das jederzeit in den Einstellungen deaktivieren.

11
docs/user/index.rst Normal file
View File

@@ -0,0 +1,11 @@
******************
User Dokumentation
******************
.. toctree::
:maxdepth: 2
:caption: Inhalt:
registrierung.rst
vermittlungen.rst
moderationskonzept.rst
benachrichtigungen.rst

View File

@@ -0,0 +1,29 @@
Moderationskonzept
==================
Vertrauen in notfellchen.org ist uns wichtig. Unser Kernziel ist es Tierschutz und Tierwohl zu fördern. Dafür sind drei
Grundkonzepte wichtig
* Aktualität: Informationen auf notfellchen.org müssen aktuell&richtig sein
* Tierschutz: Ausschließlich Ratten aus dem Tierschutz werden vermittelt
* Moderation: Vermittlungen und Kommentare können gemeldet werden und werden vom Team zügig moderiert.
Vermittlungen
+++++++++++++
Vermittlungen können von allen Nutzer*innen mit Account erstellt werden. Vermittlungen normaler Nutzer*innen kommen dann in eine Warteschlange und werden vom Admin & Modertionsteam geprüft und sichtbar geschaltet.
Tierheime und Pflegestellen können auf Anfrage einen Koordinations-Status bekommen, wodurch sie Vermittlungsanzeigen erstellen können die direkt öffentlich sichtbar sind.
Jede Vermittlung hat ein "Zuletzt-geprüft" Datum, das anzeigt, wann ein Mensch zuletzt überprüft hat, ob die Anzeige noch aktuell ist.
Nach 3 Wochen ohne Prüfung werden Anzeigen automatisch von der Seite entfernt und nur dann wieder freigeschaltet, wenn eine manuelle Prüfung erfolgt.
Darüber hinaus werden einmal täglich die verlinkten Seiten automatisiert geprüft. Wenn eine Vermittlung auf der Website eines Tierheims oder einer Pflegestelle entfernt wird, wird die Anzeige sofort deaktiviert.
Vermittlungen können von allen Menschen, auch ohne Account gemeldet werden. Grund für eine Meldung kann sein, dass Informationen veraltet sind oder ein Verdacht von Tierwohlgefärdung. Gemeldete Vermittlungen werden vom Moderationsteam geprüft und ggf. entfernt.
Kommentare
++++++++++
Die Kommentarfunktion von Vermittlungen ermöglicht es angemeldeten Nutzer*innen zusätzliche Informationen hinzuzufügen oder Fragen zu stellen.
Kommentare können, wie Vermittlungen, gemeldet werden wenn sie nicht den Regeln entsprechen.

View File

@@ -0,0 +1,10 @@
Registrierung
================================
Du kannst dich jederzeit selbst registrieren. Das geht unter https://notfellchen.org/accounts/register/
Ein Account ermöglicht es dir
* Kommentare zu hinterlassen
* Vermittlungen hinzuzufügen
* Vermittlungen zu abonnieren

View File

@@ -0,0 +1,17 @@
Vermittlungen
=============
Vermittlungen können von allen Nutzer*innen mit Account erstellt werden. Vermittlungen normaler Nutzer*innen kommen dann in eine Warteschlange und werden vom Admin & Modertionsteam geprüft und sichtbar geschaltet.
Tierheime und Pflegestellen können auf Anfrage einen Koordinations-Status bekommen, wodurch sie Vermittlungsanzeigen erstellen können die direkt öffentlich sichtbar sind.
Jede Vermittlung hat ein "Zuletzt-geprüft" Datum, das anzeigt, wann ein Mensch zuletzt überprüft hat, ob die Anzeige noch aktuell ist.
Nach 3 Wochen ohne Prüfung werden Anzeigen automatisch von der Seite entfernt und nur dann wieder freigeschaltet, wenn eine manuelle Prüfung erfolgt.
Darüber hinaus werden einmal täglich die verlinkten Seiten automatisiert geprüft. Wenn eine Vermittlungs-Seite bei einem Tierheim oder einer Pflegestelle entfernt wurde, wird die Anzeige ebenfalls deaktiviert.
Vermittlungen können von allen Menschen, auch ohne Account gemeldet werden. Grund dafür kann sein, dass Informationen veraltet sind oder ein Verdacht von Tierwohlgefärdung. Gemeldete Vermittlungen werden vom Moderationsteam geprüft und ggf. entfernt.
Die Kommentarfunktion von Vermittlungen ermöglicht es angemeldeten Nutzer*innen zusätzliche Informationen hinzuzufügen oder Fragen zu stellen.
Ersteller*innen von Vermittlungen werden über neue Kommentare per Mail benachrichtigt, ebenso alle die die Vermittlung abonniert haben.
Kommentare können, wie Vermittlungen, gemeldet werden.

View File

@@ -24,4 +24,7 @@ console-only=true
app_log_level=INFO
django_log_level=INFO
[geocoding]
api_url=https://photon.hyteck.de/api
api_format=photon

View File

@@ -8,13 +8,13 @@ build-backend = "setuptools.build_meta"
name = "notfellchen"
description = "A tool to help."
authors = [
{name = "moanos", email = "julian-samuel@gebuehr.net"},
{ name = "moanos", email = "julian-samuel@gebuehr.net" },
]
maintainers = [
{name = "moanos", email = "julian-samuel@gebuehr.net"},
{ name = "moanos", email = "julian-samuel@gebuehr.net" },
]
keywords = ["animal", "adoption", "django", "rescue", ]
license = {text = "AGPL-3.0-or-later"}
license = { text = "AGPL-3.0-or-later" }
classifiers = [
"Environment :: Web",
"License :: OSI Approved :: GNU Affero General Public License v3",
@@ -24,26 +24,35 @@ classifiers = [
]
dependencies = [
"Django",
"coverage",
"codecov",
"sphinx",
"sphinx-rtd-theme",
"gunicorn",
"fontawesomefree",
"whitenoise",
"model_bakery",
"markdown",
"Pillow",
"django-registration",
"psycopg2-binary",
"django-crispy-forms",
"crispy-bootstrap4",
"djangorestframework",
"celery[redis]",
"drf-spectacular[sidecar]"
]
dynamic = ["version", "readme"]
[project.optional-dependencies]
develop = [
"pytest",
"coverage",
"model_bakery",
]
[project.urls]
homepage = "https://hyteck.de"
repository = "https://github.com/moan0s/notfellchen/"
homepage = "https://notfellchen.org"
repository = "https://codeberg.org/moanos/notfellchen/"
[tool.setuptools.packages.find]
where = ["src"]
@@ -53,6 +62,6 @@ nf = 'notfellchen.main:main'
[tool.setuptools.dynamic]
version = {attr = "notfellchen.__version__"}
readme = {file = "README.md"}
version = { attr = "notfellchen.__version__" }
readme = { file = "README.md" }

View File

@@ -1,11 +1,17 @@
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib import admin
from django.utils.html import format_html
import csv
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice
from django.contrib import admin
from django.http import HttpResponse
from django.utils.html import format_html
from django.urls import reverse
from django.utils.http import urlencode
from .models import User, Language, Text, ReportComment, ReportAdoptionNotice, Log, Timestamp, SearchSubscription, \
SpeciesSpecificURL
from .models import Animal, Species, RescueOrganization, AdoptionNotice, Location, Rule, Image, ModerationAction, \
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions
Comment, Report, Announcement, AdoptionNoticeStatus, User, Subscriptions, BaseNotification
from django.utils.translation import gettext_lazy as _
class StatusInline(admin.StackedInline):
@@ -14,13 +20,52 @@ class StatusInline(admin.StackedInline):
@admin.register(AdoptionNotice)
class AdoptionNoticeAdmin(admin.ModelAdmin):
search_fields = ("name__icontains", "description__icontains")
list_filter = ("owner",)
inlines = [
StatusInline,
]
actions = ("activate",)
def activate(self, request, queryset):
for obj in queryset:
obj.set_active()
activate.short_description = _("Ausgewählte Vermittlungen aktivieren")
# Re-register UserAdmin
admin.site.register(User)
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
search_fields = ("usernamname__icontains", "first_name__icontains", "last_name__icontains", "email__icontains")
list_display = ("username", "email", "trust_level", "is_active", "view_adoption_notices")
list_filter = ("is_active", "trust_level",)
actions = ("export_as_csv",)
def view_adoption_notices(self, obj):
count = obj.adoption_notices.count()
url = (
reverse("admin:fellchensammlung_adoptionnotice_changelist")
+ "?"
+ urlencode({"owner__id": f"{obj.id}"})
)
return format_html('<a href="{}">{} Adoption Notices</a>', url, count)
def export_as_csv(self, request, queryset):
meta = self.model._meta
field_names = [field.name for field in meta.fields]
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename={}.csv'.format(meta)
writer = csv.writer(response)
writer.writerow(field_names)
for obj in queryset:
row = writer.writerow([getattr(obj, field) for field in field_names])
return response
export_as_csv.short_description = _("Ausgewählte User exportieren")
def _reported_content_link(obj):
@@ -49,16 +94,48 @@ class ReportAdoptionNoticeAdmin(admin.ModelAdmin):
reported_content_link.short_description = "Reported Content"
class SpeciesSpecificURLInline(admin.StackedInline):
model = SpeciesSpecificURL
@admin.register(RescueOrganization)
class RescueOrganizationAdmin(admin.ModelAdmin):
search_fields = ("name","description", "internal_comment", "location_string")
list_display = ("name", "trusted", "allows_using_materials", "website")
list_filter = ("allows_using_materials", "trusted",)
inlines = [
SpeciesSpecificURLInline,
]
@admin.register(Text)
class TextAdmin(admin.ModelAdmin):
search_fields = ("title__icontains", "text_code__icontains",)
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_filter = ("user",)
@admin.register(BaseNotification)
class BaseNotificationAdmin(admin.ModelAdmin):
list_filter = ("user", "read")
@admin.register(SearchSubscription)
class SearchSubscriptionAdmin(admin.ModelAdmin):
list_filter = ("owner",)
admin.site.register(Animal)
admin.site.register(Species)
admin.site.register(RescueOrganization)
admin.site.register(Location)
admin.site.register(Rule)
admin.site.register(Image)
admin.site.register(ModerationAction)
admin.site.register(Language)
admin.site.register(Text)
admin.site.register(Announcement)
admin.site.register(AdoptionNoticeStatus)
admin.site.register(Subscriptions)
admin.site.register(Log)
admin.site.register(Timestamp)

View File

View File

@@ -0,0 +1,53 @@
from ..models import Animal, RescueOrganization, AdoptionNotice, Species, Image
from rest_framework import serializers
class AdoptionNoticeSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = AdoptionNotice
fields = ['created_at', 'last_checked', "searching_since", "name", "description", "further_information",
"group_only"]
class AnimalCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Animal
fields = ["name", "date_of_birth", "description", "species", "sex", "adoption_notice"]
class RescueOrgSerializer(serializers.ModelSerializer):
class Meta:
model = RescueOrganization
fields = ["name", "location_string", "instagram", "facebook", "fediverse_profile", "email", "phone_number",
"website", "description", "external_object_identifier", "external_source_identifier"]
class AnimalGetSerializer(serializers.ModelSerializer):
class Meta:
model = Animal
fields = "__all__"
class RescueOrganizationSerializer(serializers.ModelSerializer):
class Meta:
model = RescueOrganization
exclude = ["internal_comment", "allows_using_materials"]
class ImageCreateSerializer(serializers.ModelSerializer):
@staticmethod
def _animal_or_an(value):
if not value in ["animal", "adoption_notice"]:
raise serializers.ValidationError(
'Set either animal or adoption_notice, depending on what type of object the image should be attached to.')
attach_to_type = serializers.CharField(validators=[_animal_or_an])
attach_to = serializers.IntegerField()
class Meta:
model = Image
exclude = ["owner"]
class SpeciesSerializer(serializers.ModelSerializer):
class Meta:
model = Species
fields = "__all__"

View File

@@ -0,0 +1,16 @@
from django.urls import path
from .views import (
AdoptionNoticeApiView,
AnimalApiView, RescueOrganizationApiView, AddImageApiView, SpeciesApiView
)
urlpatterns = [
path("adoption_notice", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-list"),
path("adoption_notice/<int:id>/", AdoptionNoticeApiView.as_view(), name="api-adoption-notice-detail"),
path("animals/", AnimalApiView.as_view(), name="api-animal-list"),
path("animals/<int:id>/", AnimalApiView.as_view(), name="api-animal-detail"),
path("organizations/", RescueOrganizationApiView.as_view(), name="api-organization-list"),
path("organizations/<int:id>/", RescueOrganizationApiView.as_view(), name="api-organization-detail"),
path("images/", AddImageApiView.as_view(), name="api-add-image"),
path("species/", SpeciesApiView.as_view(), name="api-species-list"),
]

View File

@@ -0,0 +1,212 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from django.db import transaction
from fellchensammlung.models import AdoptionNotice, Animal, Log, TrustLevel
from fellchensammlung.tasks import post_adoption_notice_save
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .serializers import (
AnimalGetSerializer,
AnimalCreateSerializer,
RescueOrganizationSerializer,
AdoptionNoticeSerializer,
ImageCreateSerializer,
SpeciesSerializer, RescueOrgSerializer,
)
from fellchensammlung.models import Animal, RescueOrganization, AdoptionNotice, Species, Image
from drf_spectacular.utils import extend_schema
class AdoptionNoticeApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
parameters=[
{
'name': 'id',
'required': False,
'description': 'ID of the adoption notice to retrieve.',
'type': int
},
],
responses={200: AdoptionNoticeSerializer(many=True)}
)
def get(self, request, *args, **kwargs):
"""
Retrieve adoption notices with their related animals and images.
"""
adoption_notice_id = kwargs.get("id")
if adoption_notice_id:
try:
adoption_notice = AdoptionNotice.objects.get(pk=adoption_notice_id)
serializer = AdoptionNoticeSerializer(adoption_notice, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
except AdoptionNotice.DoesNotExist:
return Response({"error": "Adoption notice not found."}, status=status.HTTP_404_NOT_FOUND)
adoption_notices = AdoptionNotice.objects.all()
serializer = AdoptionNoticeSerializer(adoption_notices, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic
@extend_schema(
request=AdoptionNoticeSerializer,
responses={201: 'Adoption notice created successfully!'}
)
def post(self, request, *args, **kwargs):
"""
API view to add an adoption notice.
"""
serializer = AdoptionNoticeSerializer(data=request.data, context={'request': request})
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
adoption_notice = serializer.save(owner=request.user)
# Add the location
post_adoption_notice_save.delay_on_commit(adoption_notice.pk)
# Only set active when user has trust level moderator or higher
if request.user.trust_level >= TrustLevel.MODERATOR:
adoption_notice.set_active()
else:
adoption_notice.set_unchecked()
# Log the action
Log.objects.create(
user=request.user,
action="add_adoption_notice",
text=f"{request.user} added adoption notice {adoption_notice.pk} via API",
)
# Return success response with new adoption notice details
return Response(
{"message": "Adoption notice created successfully!", "id": adoption_notice.pk},
status=status.HTTP_201_CREATED,
)
class AnimalApiView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
"""
Get list of animals or a specific animal by ID.
"""
animal_id = kwargs.get("id")
if animal_id:
try:
animal = Animal.objects.get(pk=animal_id)
serializer = AnimalGetSerializer(animal, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
except Animal.DoesNotExist:
return Response({"error": "Animal not found."}, status=status.HTTP_404_NOT_FOUND)
animals = Animal.objects.all()
serializer = AnimalGetSerializer(animals, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic
def post(self, request, *args, **kwargs):
"""
Create a new animal.
"""
serializer = AnimalCreateSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
animal = serializer.save(owner=request.user)
return Response(
{"message": "Animal created successfully!", "id": animal.id},
status=status.HTTP_201_CREATED,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RescueOrganizationApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
parameters=[
{
'name': 'id',
'required': False,
'description': 'ID of the rescue organization to retrieve.',
'type': int
},
],
responses={200: RescueOrganizationSerializer(many=True)}
)
def get(self, request, *args, **kwargs):
"""
Get list of rescue organizations or a specific organization by ID.
"""
org_id = kwargs.get("id")
if org_id:
try:
organization = RescueOrganization.objects.get(pk=org_id)
serializer = RescueOrganizationSerializer(organization, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
except RescueOrganization.DoesNotExist:
return Response({"error": "Organization not found."}, status=status.HTTP_404_NOT_FOUND)
organizations = RescueOrganization.objects.all()
serializer = RescueOrganizationSerializer(organizations, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)
@transaction.atomic
@extend_schema(
request=RescueOrgSerializer, # Document the request body
responses={201: 'Rescue organization created/updated successfully!'}
)
def post(self, request, *args, **kwargs):
"""
Create or update a rescue organization.
"""
serializer = RescueOrgSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
rescue_org = serializer.save(owner=request.user)
return Response(
{"message": "Rescue organization created/updated successfully!", "id": rescue_org.id},
status=status.HTTP_201_CREATED,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AddImageApiView(APIView):
permission_classes = [IsAuthenticated]
@transaction.atomic
@extend_schema(
request=ImageCreateSerializer,
responses={201: 'Image added successfully!'}
)
def post(self, request, *args, **kwargs):
"""
Add an image to an animal or adoption notice.
"""
serializer = ImageCreateSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
if serializer.validated_data["attach_to_type"] == "animal":
object_to_attach_to = Animal.objects.get(id=serializer.validated_data["attach_to"])
elif serializer.validated_data["attach_to_type"] == "adoption_notice":
object_to_attach_to = AdoptionNotice.objects.get(id=serializer.validated_data["attach_to"])
else:
raise ValueError("Unknown attach_to_type given, should not happen. Check serializer")
serializer.validated_data.pop('attach_to_type', None)
serializer.validated_data.pop('attach_to', None)
image = serializer.save(owner=request.user)
object_to_attach_to.photos.add(image)
return Response(
{"message": "Image added successfully!", "id": image.id},
status=status.HTTP_201_CREATED,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class SpeciesApiView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
responses={200: SpeciesSerializer(many=True)}
)
def get(self, request, *args, **kwargs):
"""
Retrieve a list of species.
"""
species = Species.objects.all()
serializer = SpeciesSerializer(species, many=True, context={"request": request})
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -15,3 +15,4 @@ class FellchensammlungConfig(AppConfig):
except Permission.DoesNotExist:
pass
post_migrate.connect(ensure_languages, sender=self)
import fellchensammlung.receivers

View File

@@ -1,14 +1,21 @@
import datetime
from django import forms
from .models import AdoptionNotice, Animal, Image, ReportAdoptionNotice, ReportComment, ModerationAction, User, Species, \
Comment
Comment, SexChoicesWithAll, DistanceChoices
from django_registration.forms import RegistrationForm
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout, Fieldset, HTML, Row, Column, Field
from crispy_forms.layout import Submit, Layout, Fieldset, HTML, Row, Column, Field, Hidden
from django.utils.translation import gettext_lazy as _
from notfellchen.settings import MEDIA_URL
from crispy_forms.layout import Div
def animal_validator(value: str):
value = value.lower()
animal_list = ["ratte", "farbratte", "katze", "hund", "kaninchen", "hase", "kuh", "fuchs", "cow", "rat", "cat",
"dog", "rabbit", "fox", "fancy rat"]
if value not in animal_list:
raise forms.ValidationError(_("Dieses Tier kenne ich nicht. Probier ein anderes"))
class DateInput(forms.DateInput):
@@ -32,7 +39,7 @@ class AdoptionNoticeForm(forms.ModelForm):
submit = Submit('save-and-add-another-animal', _('Speichern'))
else:
submit = Submit('submit', _('Sepichern'))
submit = Submit('submit', _('Speichern'))
self.helper.layout = Layout(
Fieldset(
@@ -45,6 +52,7 @@ class AdoptionNoticeForm(forms.ModelForm):
'group_only',
'searching_since',
'location_string',
'organization',
'description',
'further_information',
),
@@ -52,19 +60,20 @@ class AdoptionNoticeForm(forms.ModelForm):
class Meta:
model = AdoptionNotice
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string"]
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string",
"organization"]
class AdoptionNoticeFormWithDateWidget(AdoptionNoticeForm):
class Meta:
model = AdoptionNotice
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string"]
fields = ['name', "group_only", "further_information", "description", "searching_since", "location_string",
"organization"]
widgets = {
'searching_since': DateInput(),
}
class AnimalForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
if 'in_adoption_notice_creation_flow' in kwargs:
@@ -93,6 +102,7 @@ class AnimalFormWithDateWidget(AnimalForm):
'date_of_birth': DateInput(),
}
class AdoptionNoticeFormWithDateWidgetAutoAnimal(AdoptionNoticeFormWithDateWidget):
def __init__(self, *args, **kwargs):
super(AdoptionNoticeFormWithDateWidgetAutoAnimal, self).__init__(*args, **kwargs)
@@ -115,11 +125,21 @@ class ImageForm(forms.ModelForm):
self.helper.form_id = 'form-animal-photo'
self.helper.form_class = 'card'
self.helper.form_method = 'post'
if in_flow:
self.helper.add_input(Submit('save-and-add-another', _('Speichern und weiteres Foto hinzufügen')))
self.helper.add_input(Submit('submit', _('Speichern')))
submits= Div(Submit('submit', _('Speichern')),
Submit('save-and-add-another', _('Speichern und weiteres Foto hinzufügen')), css_class="container-edit-buttons")
else:
self.helper.add_input(Submit('submit', _('Submit')))
submits = Fieldset(Submit('submit', _('Speichern')), css_class="container-edit-buttons")
self.helper.layout = Layout(
Div(
'image',
'alt_text',
css_class="spaced",
),
submits
)
class Meta:
model = Image
@@ -143,6 +163,7 @@ class CommentForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_class = 'form-comments'
self.helper.add_input(Hidden('action', 'comment'))
self.helper.add_input(Submit('submit', _('Kommentieren'), css_class="btn2"))
class Meta:
@@ -160,6 +181,8 @@ class CustomRegistrationForm(RegistrationForm):
class Meta(RegistrationForm.Meta):
model = User
captcha = forms.CharField(validators=[animal_validator], label=_("Nenne eine bekannte Tierart"), help_text=_("Bitte nenne hier eine bekannte Tierart (z.B. ein Tier das an der Leine geführt wird). Das Fragen wir dich um sicherzustellen, dass du kein Roboter bist."))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
@@ -169,10 +192,8 @@ class CustomRegistrationForm(RegistrationForm):
self.helper.add_input(Submit('submit', _('Registrieren'), css_class="btn"))
def _get_distances():
return {i: i for i in [10, 20, 50, 100, 200, 500]}
class AdoptionNoticeSearchForm(forms.Form):
postcode = forms.CharField(max_length=20, label=_("Postleitzahl"))
max_distance = forms.ChoiceField(choices=_get_distances, label=_("Max. Distanz"))
sex = forms.ChoiceField(choices=SexChoicesWithAll, label=_("Geschlecht"), required=False,
initial=SexChoicesWithAll.ALL)
max_distance = forms.ChoiceField(choices=DistanceChoices, initial=DistanceChoices.ONE_HUNDRED, label=_("Suchradius"))
location_string = forms.CharField(max_length=100, label=_("Stadt"), required=False)

View File

@@ -1,18 +1,18 @@
import django.conf.global_settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext
from django.conf import settings
from django.core import mail
from django.db.models import Q, Min
from fellchensammlung.models import User
from fellchensammlung.models import User, CommentNotification, BaseNotification, TrustLevel
from notfellchen.settings import host
NEWLINE = "\r\n"
def mail_admins_new_report(report):
subject = _("Neue Meldung")
for moderator in User.objects.filter(trust_level__gt=User.TRUST_LEVEL[User.MODERATOR]):
for moderator in User.objects.filter(trust_level__gt=TrustLevel.MODERATOR):
greeting = _("Moin,") + "{NEWLINE}"
new_report_text = _("es wurde ein Regelverstoß gemeldet.") + "{NEWLINE}"
if len(report.reported_broken_rules.all()) > 0:
@@ -29,5 +29,15 @@ def mail_admins_new_report(report):
link_text = f"Um alle Details zu sehen, geh bitte auf: {report_url}"
body_text = greeting + new_report_text + reported_rules_text + comment_text + link_text
message = mail.EmailMessage(subject, body_text, settings.DEFAULT_FROM_EMAIL, [moderator.email])
print("Sending email to ", moderator.email)
message.send()
def send_notification_email(notification_pk):
try:
notification = CommentNotification.objects.get(pk=notification_pk)
except CommentNotification.DoesNotExist:
notification = BaseNotification.objects.get(pk=notification_pk)
subject = f"🔔 {notification.title}"
body_text = notification.text
message = mail.EmailMessage(subject, body_text, settings.DEFAULT_FROM_EMAIL, [notification.user.email])
message.send()

View File

@@ -1,6 +1,6 @@
from django.core.management import BaseCommand
from fellchensammlung.models import AdoptionNotice, Location
from fellchensammlung.tools.geo import clean_locations
from fellchensammlung.tools.admin import clean_locations
class Command(BaseCommand):
@@ -15,4 +15,4 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
clean_locations(quiet=False)
clean_locations(quiet=False)

View File

@@ -7,7 +7,7 @@ from fellchensammlung import baker_recipes
from model_bakery import baker
from fellchensammlung.models import AdoptionNotice, Species, Animal, Image, ModerationAction, User, Rule, \
Report, Comment, ReportAdoptionNotice
Report, Comment, ReportAdoptionNotice, TrustLevel
class Command(BaseCommand):
@@ -101,10 +101,10 @@ class Command(BaseCommand):
User.objects.create_user('test', password='foobar')
admin1 = User.objects.create_superuser(username="admin", password="admin", email="admin1@example.org",
trust_level=User.TRUST_LEVEL[User.ADMIN])
trust_level=TrustLevel.ADMIN)
mod1 = User.objects.create_user(username="mod1", password="mod", email="mod1@example.org",
trust_level=User.TRUST_LEVEL[User.MODERATOR])
trust_level=TrustLevel.MODERATOR)
comment1 = baker.make(Comment, user=admin1, text="This is a comment", adoption_notice=adoption1)
comment2 = baker.make(Comment,

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2024-09-30 12:24
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0005_rescueorganization_allows_using_materials_and_more'),
]
operations = [
migrations.AddField(
model_name='adoptionnotice',
name='last_checked',
field=models.DateField(default=datetime.datetime.now, verbose_name='Zuletzt überprüft am'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2024-09-30 13:10
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0006_adoptionnotice_last_checked'),
]
operations = [
migrations.AlterField(
model_name='adoptionnotice',
name='last_checked',
field=models.DateTimeField(default=datetime.datetime.now, verbose_name='Zuletzt überprüft am'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-10-10 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0007_alter_adoptionnotice_last_checked'),
]
operations = [
migrations.AlterField(
model_name='adoptionnoticestatus',
name='minor_status',
field=models.CharField(choices=[('searching', 'searching'), ('interested', 'interested'), ('waiting_for_review', 'waiting_for_review'), ('needs_additional_info', 'needs_additional_info'), ('successful_with_notfellchen', 'successful_with_notfellchen'), ('successful_without_notfellchen', 'successful_without_notfellchen'), ('animal_died', 'animal_died'), ('closed_for_other_adoption_notice', 'closed_for_other_adoption_notice'), ('not_open_for_adoption_anymore', 'not_open_for_adoption_anymore'), ('other', 'other'), ('against_the_rules', 'against_the_rules'), ('missing_information', 'missing_information'), ('technical_error', 'technical_error'), ('unchecked', 'unchecked')], max_length=200),
),
migrations.AlterField(
model_name='announcement',
name='publish_start_time',
field=models.DateTimeField(verbose_name='Veröffentlichungszeitpunkt'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.1 on 2024-10-10 21:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0008_alter_adoptionnoticestatus_minor_status_and_more'),
]
operations = [
migrations.CreateModel(
name='Log',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=255, verbose_name='Aktion')),
('text', models.CharField(max_length=1000, verbose_name='Log text')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Nutzer*in')),
],
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.1.1 on 2024-10-19 18:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0009_log'),
]
operations = [
migrations.CreateModel(
name='Timestamp',
fields=[
('key', models.CharField(max_length=255, primary_key=True, serialize=False, verbose_name='Schlüssel')),
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Zeitstempel')),
('data', models.CharField(blank=True, max_length=2000, null=True)),
],
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.1 on 2024-10-29 10:44
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0010_timestamp'),
]
operations = [
migrations.AlterField(
model_name='adoptionnotice',
name='created_at',
field=models.DateField(default=django.utils.timezone.now, verbose_name='Erstellt am'),
),
migrations.AlterField(
model_name='adoptionnotice',
name='last_checked',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Zuletzt überprüft am'),
),
]

View File

@@ -0,0 +1,136 @@
# Generated by Django 5.1.1 on 2024-11-03 20:07
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0011_alter_adoptionnotice_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='adoptionnotice',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='adoptionnoticestatus',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='adoptionnoticestatus',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='animal',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='animal',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='announcement',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='basenotification',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='comment',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='image',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='image',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='location',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='location',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='log',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='moderationaction',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='report',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='rescueorganization',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='rescueorganization',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='rule',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='rule',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='species',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='species',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='subscriptions',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='user',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.1 on 2024-11-06 07:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0012_adoptionnotice_updated_at_and_more'),
]
operations = [
migrations.AlterField(
model_name='log',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Nutzer*in'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-07 20:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0013_alter_log_user'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-Mail'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-09 09:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0014_rescueorganization_email'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='comment',
field=models.TextField(blank=True, null=True, verbose_name='Kommentar'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-13 15:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0015_rescueorganization_comment'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='phone_number',
field=models.CharField(blank=True, max_length=15, null=True, verbose_name='Telefonnummer'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2024-11-14 06:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0016_rescueorganization_phone_number'),
]
operations = [
migrations.AddField(
model_name='user',
name='organization_affiliation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.rescueorganization', verbose_name='Organisation'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-14 17:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0017_user_organization_affiliation'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='description',
field=models.TextField(blank=True, null=True, verbose_name='Beschreibung'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-14 18:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0018_rescueorganization_description'),
]
operations = [
migrations.RenameField(
model_name='rescueorganization',
old_name='comment',
new_name='internal_comment',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-14 18:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0019_rename_comment_rescueorganization_internal_comment'),
]
operations = [
migrations.AlterField(
model_name='rescueorganization',
name='internal_comment',
field=models.TextField(blank=True, null=True, verbose_name='Interner Kommentar'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2024-11-14 20:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0020_alter_rescueorganization_internal_comment'),
]
operations = [
migrations.AddField(
model_name='user',
name='reason_for_signup',
field=models.TextField(default='-', verbose_name='Grund für die Registrierung'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-11-20 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0021_user_reason_for_signup'),
]
operations = [
migrations.AlterField(
model_name='user',
name='reason_for_signup',
field=models.TextField(help_text="Wir würden gerne wissen warum du dich registriertst, ob du dich z.B. Tiere eines bestimmten Tierheim einstellen willst 'nur mal gucken' willst. Beides ist toll! Wenn du für ein Tierheim/eine Pflegestelle arbeitest kontaktieren wir dich ggf. um dir erweiterte Rechte zu geben.", verbose_name='Grund für die Registrierung'),
),
migrations.AlterField(
model_name='user',
name='trust_level',
field=models.IntegerField(choices=[(1, 'Member'), (2, 'Coordinator'), (3, 'Moderator'), (4, 'Admin')], default=1),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-20 19:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0022_alter_user_reason_for_signup_alter_user_trust_level'),
]
operations = [
migrations.AddField(
model_name='user',
name='email_notifications',
field=models.BooleanField(default=True, verbose_name='Benachrichtigung per E-Mail'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-21 19:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0023_user_email_notifications'),
]
operations = [
migrations.AlterField(
model_name='animal',
name='sex',
field=models.CharField(choices=[('M_N', 'neutered male'), ('M', 'male'), ('F_N', 'neutered female'), ('F', 'female'), ('I', 'intersex')], max_length=20),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-21 19:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0024_alter_animal_sex'),
]
operations = [
migrations.AlterField(
model_name='animal',
name='sex',
field=models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich Kastriert'), ('I', 'Intersex')], max_length=20),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-11-21 21:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0025_alter_animal_sex'),
]
operations = [
migrations.AlterField(
model_name='animal',
name='sex',
field=models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich, kastriert'), ('I', 'Intergeschlechtlich')], max_length=20),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.1.1 on 2024-12-14 07:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0026_alter_animal_sex'),
]
operations = [
migrations.AlterField(
model_name='animal',
name='species',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.species', verbose_name='Tierart'),
),
migrations.CreateModel(
name='AndoptionNoticeNotification',
fields=[
('basenotification_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fellchensammlung.basenotification')),
('adoption_notice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.adoptionnotice', verbose_name='Vermittlung')),
],
bases=('fellchensammlung.basenotification',),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.1 on 2024-12-26 15:56
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0027_alter_animal_species_andoptionnoticenotification'),
]
operations = [
migrations.CreateModel(
name='SearchSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sex', models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich, kastriert'), ('I', 'Intergeschlechtlich')], max_length=20)),
('radius', models.IntegerField(choices=[(20, '20 km'), (50, '50 km'), (100, '100 km'), (200, '200 km'), (500, '500 km')])),
('location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.location')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.1.4 on 2024-12-31 10:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0028_searchsubscription'),
]
operations = [
migrations.RenameModel(
old_name='AndoptionNoticeNotification',
new_name='AdoptionNoticeNotification',
),
migrations.AlterField(
model_name='searchsubscription',
name='sex',
field=models.CharField(choices=[('F', 'Weiblich'), ('M', 'Männlich'), ('M_N', 'Männlich, kastriert'), ('F_N', 'Weiblich Kastriert'), ('I', 'Intergeschlechtlich'), ('A', 'Alle')], max_length=20),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2024-12-31 12:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0029_rename_andoptionnoticenotification_adoptionnoticenotification_and_more'),
]
operations = [
migrations.RenameField(
model_name='searchsubscription',
old_name='radius',
new_name='max_distance',
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.4 on 2024-12-31 12:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0030_rename_radius_searchsubscription_max_distance'),
]
operations = [
migrations.AlterField(
model_name='searchsubscription',
name='location',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fellchensammlung.location'),
),
migrations.AlterField(
model_name='searchsubscription',
name='max_distance',
field=models.IntegerField(choices=[(20, '20 km'), (50, '50 km'), (100, '100 km'), (200, '200 km'), (500, '500 km')], null=True),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.4 on 2025-01-01 22:04
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0031_alter_searchsubscription_location_and_more'),
]
operations = [
migrations.AddField(
model_name='searchsubscription',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='searchsubscription',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-01-05 18:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0032_searchsubscription_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='rescueorganization',
name='external_object_identifier',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='External Object Identifier'),
),
migrations.AddField(
model_name='rescueorganization',
name='external_source_identifier',
field=models.CharField(blank=True, choices=[('OSM', 'Open Street Map')], max_length=200, null=True, verbose_name='External Source Identifier'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-01-05 19:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0033_rescueorganization_external_object_identifier_and_more'),
]
operations = [
migrations.CreateModel(
name='SpeciesSpecificURL',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(verbose_name='Tierartspezifische URL')),
('rescues_organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.rescueorganization', verbose_name='Tierschutzorganisation')),
('species', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fellchensammlung.species', verbose_name='Tierart')),
],
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.4 on 2025-01-11 12:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0034_speciesspecificurl'),
]
operations = [
migrations.AlterField(
model_name='image',
name='alt_text',
field=models.TextField(max_length=2000, verbose_name='Alternativtext'),
),
migrations.AlterField(
model_name='report',
name='reported_broken_rules',
field=models.ManyToManyField(to='fellchensammlung.rule', verbose_name='Regeln gegen die verstoßen wurde'),
),
migrations.AlterField(
model_name='report',
name='user_comment',
field=models.TextField(blank=True, verbose_name='Kommentar/Zusätzliche Information'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-01-14 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0035_alter_image_alt_text_and_more'),
]
operations = [
migrations.AddField(
model_name='basenotification',
name='read_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Gelesen am'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-01-14 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fellchensammlung', '0036_basenotification_read_at'),
]
operations = [
migrations.AlterField(
model_name='basenotification',
name='title',
field=models.CharField(max_length=100, verbose_name='Titel'),
),
]

View File

@@ -1,9 +1,10 @@
import uuid
from random import choices
from tabnanny import verbose
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from datetime import datetime
from django.utils import timezone
from django.dispatch import receiver
from django.db.models.signals import post_save
@@ -12,6 +13,8 @@ from django.contrib.auth.models import AbstractUser
from .tools import misc, geo
from notfellchen.settings import MEDIA_URL
from .tools.geo import LocationProxy, Position
from .tools.misc import age_as_hr_string, time_since_as_hr_string
class Language(models.Model):
@@ -35,105 +38,55 @@ class Language(models.Model):
verbose_name_plural = _('Sprachen')
class User(AbstractUser):
"""
Model that holds a user's profile, including the django user model
The trust levels act as permission system and can be displayed as a badge for the user
"""
# Admins can perform all actions and have the highest trust associated with them
# Moderators can make moderation decisions regarding the deletion of content
# Coordinators can create adoption notices without them being checked
# Members can create adoption notices that must be activated
ADMIN = "admin"
MODERATOR = "Moderator"
COORDINATOR = "Koordinator*in"
MEMBER = "Mitglied"
TRUST_LEVEL = {
ADMIN: 4,
MODERATOR: 3,
COORDINATOR: 2,
MEMBER: 1,
}
preferred_language = models.ForeignKey(Language, on_delete=models.PROTECT, null=True, blank=True,
verbose_name=_('Bevorzugte Sprache'))
trust_level = models.IntegerField(choices=TRUST_LEVEL, default=TRUST_LEVEL[MEMBER])
class Meta:
verbose_name = _('Nutzer*in')
verbose_name_plural = _('Nutzer*innen')
def get_absolute_url(self):
return reverse("user-detail", args=[str(self.pk)])
def get_notifications_url(self):
return self.get_absolute_url()
def get_num_unread_notifications(self):
return BaseNotification.objects.filter(user=self,read=False).count()
@property
def owner(self):
return self
class Image(models.Model):
image = models.ImageField(upload_to='images')
alt_text = models.TextField(max_length=2000)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
return self.alt_text
@property
def as_html(self):
return f'<img src="{MEDIA_URL}/{self.image}" alt="{self.alt_text}">'
class Species(models.Model):
"""Model representing a species of animal."""
name = models.CharField(max_length=200, help_text=_('Name der Tierart'),
verbose_name=_('Name'))
def __str__(self):
"""String for representing the Model object."""
return self.name
class Meta:
verbose_name = _('Tierart')
verbose_name_plural = _('Tierarten')
class Location(models.Model):
place_id = models.IntegerField()
place_id = models.IntegerField() # OSM id
latitude = models.FloatField()
longitude = models.FloatField()
name = models.CharField(max_length=2000)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.latitude:.5}, {self.longitude:.5})"
@property
def position(self):
return (self.latitude, self.longitude)
@property
def str_hr(self):
return f"{self.name.split(',')[0]}"
@staticmethod
def get_location_from_string(location_string):
geo_api = geo.GeoAPI()
geojson = geo_api.get_geojson_for_query(location_string)
if geojson is None:
try:
proxy = LocationProxy(location_string)
except ValueError:
return None
result = geojson[0]
if "name" in result:
name = result["name"]
else:
name = result["display_name"]
location = Location.get_location_from_proxy(proxy)
return location
@staticmethod
def get_location_from_proxy(proxy):
location = Location.objects.create(
place_id=result["place_id"],
latitude=result["lat"],
longitude=result["lon"],
name=name,
place_id=proxy.place_id,
latitude=proxy.latitude,
longitude=proxy.longitude,
name=proxy.name,
)
return location
@staticmethod
def add_location_to_object(instance):
"""Search the location given in the location string and add it to the object"""
location = Location.get_location_from_string(instance.location_string)
instance.location = location
instance.save()
class ExternalSourceChoices(models.TextChoices):
OSM = "OSM", _("Open Street Map")
class RescueOrganization(models.Model):
def __str__(self):
@@ -155,13 +108,142 @@ class RescueOrganization(models.Model):
name = models.CharField(max_length=200)
trusted = models.BooleanField(default=False, verbose_name=_('Vertrauenswürdig'))
allows_using_materials = models.CharField(max_length=200,default=ALLOW_USE_MATERIALS_CHOICE[USE_MATERIALS_NOT_ASKED], choices=ALLOW_USE_MATERIALS_CHOICE, verbose_name=_('Erlaubt Nutzung von Inhalten'))
allows_using_materials = models.CharField(max_length=200,
default=ALLOW_USE_MATERIALS_CHOICE[USE_MATERIALS_NOT_ASKED],
choices=ALLOW_USE_MATERIALS_CHOICE,
verbose_name=_('Erlaubt Nutzung von Inhalten'))
location_string = models.CharField(max_length=200, verbose_name=_("Ort der Organisation"))
location = models.ForeignKey(Location, on_delete=models.PROTECT, blank=True, null=True)
instagram = models.URLField(null=True, blank=True, verbose_name=_('Instagram Profil'))
facebook = models.URLField(null=True, blank=True, verbose_name=_('Facebook Profil'))
fediverse_profile = models.URLField(null=True, blank=True, verbose_name=_('Fediverse Profil'))
email = models.EmailField(null=True, blank=True, verbose_name=_('E-Mail'))
phone_number = models.CharField(max_length=15, null=True, blank=True, verbose_name=_('Telefonnummer'))
website = models.URLField(null=True, blank=True, verbose_name=_('Website'))
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
internal_comment = models.TextField(verbose_name=_("Interner Kommentar"), null=True, blank=True, )
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung')) # Markdown allowed
external_object_identifier = models.CharField(max_length=200, null=True, blank=True,
verbose_name=_('External Object Identifier'))
external_source_identifier = models.CharField(max_length=200, null=True, blank=True,
choices=ExternalSourceChoices.choices,
verbose_name=_('External Source Identifier'))
def get_absolute_url(self):
return reverse("rescue-organization-detail", args=[str(self.pk)])
@property
def adoption_notices(self):
return AdoptionNotice.objects.filter(organization=self)
@property
def position(self):
if self.location:
return Position(latitude=self.location.latitude, longitude=self.location.longitude)
else:
return None
@property
def description_short(self):
if self.description is None:
return ""
if len(self.description) > 200:
return self.description[:200] + f" ... [weiterlesen]({self.get_absolute_url()})"
# Admins can perform all actions and have the highest trust associated with them
# Moderators can make moderation decisions regarding the deletion of content
# Coordinators can create adoption notices without them being checked
# Members can create adoption notices that must be activated
class TrustLevel(models.IntegerChoices):
MEMBER = 1, 'Member'
COORDINATOR = 2, 'Coordinator'
MODERATOR = 3, 'Moderator'
ADMIN = 4, 'Admin'
class User(AbstractUser):
"""
Model that holds a user's profile, including the django user model
The trust levels act as permission system and can be displayed as a badge for the user
"""
trust_level = models.IntegerField(
choices=TrustLevel.choices,
default=TrustLevel.MEMBER, # Default to the lowest trust level
)
preferred_language = models.ForeignKey(Language, on_delete=models.PROTECT, null=True, blank=True,
verbose_name=_('Bevorzugte Sprache'))
updated_at = models.DateTimeField(auto_now=True)
organization_affiliation = models.ForeignKey(RescueOrganization, on_delete=models.PROTECT, null=True, blank=True,
verbose_name=_('Organisation'))
reason_for_signup = models.TextField(verbose_name=_("Grund für die Registrierung"), help_text=_(
"Wir würden gerne wissen warum du dich registriertst, ob du dich z.B. Tiere eines bestimmten Tierheim einstellen willst 'nur mal gucken' willst. Beides ist toll! Wenn du für ein Tierheim/eine Pflegestelle arbeitest kontaktieren wir dich ggf. um dir erweiterte Rechte zu geben."))
email_notifications = models.BooleanField(verbose_name=_("Benachrichtigung per E-Mail"), default=True)
REQUIRED_FIELDS = ["reason_for_signup", "email"]
class Meta:
verbose_name = _('Nutzer*in')
verbose_name_plural = _('Nutzer*innen')
def get_full_name(self):
if self.first_name and self.last_name:
return self.first_name + self.last_name
else:
return self.username
def get_absolute_url(self):
return reverse("user-detail", args=[str(self.pk)])
def get_notifications_url(self):
return self.get_absolute_url()
def get_unread_notifications(self):
return BaseNotification.objects.filter(user=self, read=False)
def get_num_unread_notifications(self):
return BaseNotification.objects.filter(user=self, read=False).count()
@property
def adoption_notices(self):
return AdoptionNotice.objects.filter(owner=self)
@property
def owner(self):
return self
class Image(models.Model):
image = models.ImageField(upload_to='images')
alt_text = models.TextField(max_length=2000, verbose_name=_('Alternativtext'))
owner = models.ForeignKey(User, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.alt_text
@property
def as_html(self):
return f'<img src="{MEDIA_URL}/{self.image}" alt="{self.alt_text}">'
class Species(models.Model):
"""Model representing a species of animal."""
name = models.CharField(max_length=200, help_text=_('Name der Tierart'),
verbose_name=_('Name'))
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
"""String for representing the Model object."""
return self.name
class Meta:
verbose_name = _('Tierart')
verbose_name_plural = _('Tierarten')
class AdoptionNotice(models.Model):
@@ -171,9 +253,13 @@ class AdoptionNotice(models.Model):
]
def __str__(self):
return f"{self.name}"
if not hasattr(self, 'adoptionnoticestatus'):
return self.name
return f"[{self.adoptionnoticestatus.as_string()}] {self.name}"
created_at = models.DateField(verbose_name=_('Erstellt am'), default=datetime.now)
created_at = models.DateField(verbose_name=_('Erstellt am'), default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
last_checked = models.DateTimeField(verbose_name=_('Zuletzt überprüft am'), default=timezone.now)
searching_since = models.DateField(verbose_name=_('Sucht nach einem Zuhause seit'))
name = models.CharField(max_length=200)
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
@@ -190,6 +276,31 @@ class AdoptionNotice(models.Model):
def animals(self):
return Animal.objects.filter(adoption_notice=self)
@property
def sexes(self):
sexes = set()
for animal in self.animals:
sexes.add(animal.sex)
return sexes
@property
def last_checked_hr(self):
time_since_last_checked = timezone.now() - self.last_checked
return time_since_as_hr_string(time_since_last_checked)
def sex_code(self):
# Treat Intersex as mixed in order to increase their visibility
if len(self.sexes) > 1:
return "mixed"
sex = self.sexes.pop()
if sex == SexChoices.MALE:
return "male"
elif sex == SexChoices.FEMALE:
return "female"
else:
return "mixed"
@property
def comments(self):
return Comment.objects.filter(adoption_notice=self)
@@ -209,15 +320,22 @@ class AdoptionNotice(models.Model):
return self.description[:200] + f" ... [weiterlesen]({self.get_absolute_url()})"
def get_absolute_url(self):
"""Returns the url to access a detailed page for the animal."""
"""Returns the url to access a detailed page for the adoption notice."""
return reverse('adoption-notice-detail', args=[str(self.id)])
def get_report_url(self):
"""Returns the url to report an adoption notice."""
return reverse('report-adoption-notice', args=[str(self.id)])
def get_subscriptions(self):
# returns all subscriptions to that adoption notice
return Subscriptions.objects.filter(adoption_notice=self)
@staticmethod
def get_active_ANs():
active_ans = [an for an in AdoptionNotice.objects.all() if an.is_active]
return active_ans
def get_photos(self):
"""
First trys to get group photos that are attached to the adoption notice if there is none it trys to fetch
@@ -274,6 +392,36 @@ class AdoptionNotice(models.Model):
return False
return self.adoptionnoticestatus.is_active
@property
def is_disabled_unchecked(self):
if not hasattr(self, 'adoptionnoticestatus'):
return False
return self.adoptionnoticestatus.is_disabled_unchecked
def set_closed(self):
self.last_checked = timezone.now()
self.save()
self.adoptionnoticestatus.set_closed()
def set_active(self):
self.last_checked = timezone.now()
self.save()
if not hasattr(self, 'adoptionnoticestatus'):
AdoptionNoticeStatus.create_other(self)
self.adoptionnoticestatus.set_active()
def set_unchecked(self):
self.last_checked = timezone.now()
self.save()
if not hasattr(self, 'adoptionnoticestatus'):
AdoptionNoticeStatus.create_other(self)
self.adoptionnoticestatus.set_unchecked()
for subscription in self.get_subscriptions():
notification_title = _("Vermittlung deaktiviert:") + f" {self.name}"
text = _("Die folgende Vermittlung wurde deaktiviert: ") + f"[{self.name}]({self.get_absolute_url()})"
BaseNotification.objects.create(user=subscription.owner, text=text, title=notification_title)
class AdoptionNoticeStatus(models.Model):
"""
@@ -313,6 +461,7 @@ class AdoptionNoticeStatus(models.Model):
"against_the_rules": "against_the_rules",
"missing_information": "missing_information",
"technical_error": "technical_error",
"unchecked": "unchecked",
"other": "other"
}
}
@@ -323,46 +472,90 @@ class AdoptionNoticeStatus(models.Model):
minor_choices.update(MINOR_STATUS_CHOICES[key])
minor_status = models.CharField(choices=minor_choices, max_length=200)
adoption_notice = models.OneToOneField(AdoptionNotice, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.adoption_notice}: {self.major_status}, {self.minor_status}"
def as_string(self):
return f"{self.major_status}, {self.minor_status}"
@property
def is_active(self):
return self.major_status == self.ACTIVE
@property
def is_disabled_unchecked(self):
return self.major_status == self.DISABLED and self.minor_status == "unchecked"
@staticmethod
def get_minor_choices(major_status):
return AdoptionNoticeStatus.MINOR_STATUS_CHOICES[major_status]
@staticmethod
def create_other(an_instance):
# Used as empty status to be changed immediately
major_status = AdoptionNoticeStatus.DISABLED
minor_status = AdoptionNoticeStatus.MINOR_STATUS_CHOICES[AdoptionNoticeStatus.DISABLED]["other"]
AdoptionNoticeStatus.objects.create(major_status=major_status,
minor_status=minor_status,
adoption_notice=an_instance)
def set_closed(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.CLOSED]
self.minor_status = self.MINOR_STATUS_CHOICES[self.CLOSED]["other"]
self.save()
def set_unchecked(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.DISABLED]
self.minor_status = self.MINOR_STATUS_CHOICES[self.DISABLED]["unchecked"]
self.save()
def set_active(self):
self.major_status = self.MAJOR_STATUS_CHOICES[self.ACTIVE]
self.minor_status = self.MINOR_STATUS_CHOICES[self.ACTIVE]["searching"]
self.save()
class SexChoices(models.TextChoices):
FEMALE = "F", _("Weiblich")
MALE = "M", _("Männlich")
MALE_NEUTERED = "M_N", _("Männlich, kastriert")
FEMALE_NEUTERED = "F_N", _("Weiblich, kastriert")
INTER = "I", _("Intergeschlechtlich")
class SexChoicesWithAll(models.TextChoices):
FEMALE = "F", _("Weiblich")
MALE = "M", _("Männlich")
MALE_NEUTERED = "M_N", _("Männlich, kastriert")
FEMALE_NEUTERED = "F_N", _("Weiblich Kastriert")
INTER = "I", _("Intergeschlechtlich")
ALL = "A", _("Alle")
class Animal(models.Model):
MALE_NEUTERED = "M_N"
MALE = "M"
FEMALE_NEUTERED = "F_N"
FEMALE = "F"
SEX_CHOICES = {
MALE_NEUTERED: "neutered male",
MALE: "male",
FEMALE_NEUTERED: "neutered female",
FEMALE: "female",
}
date_of_birth = models.DateField(verbose_name=_('Geburtsdatum'))
name = models.CharField(max_length=200)
description = models.TextField(null=True, blank=True, verbose_name=_('Beschreibung'))
species = models.ForeignKey(Species, on_delete=models.PROTECT)
species = models.ForeignKey(Species, on_delete=models.PROTECT, verbose_name=_("Tierart"))
photos = models.ManyToManyField(Image, blank=True)
sex = models.CharField(max_length=20, choices=SEX_CHOICES, )
sex = models.CharField(
max_length=20,
choices=SexChoices.choices,
)
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name}"
@property
def age(self):
return datetime.today().date() - self.date_of_birth
return timezone.now().today().date() - self.date_of_birth
@property
def hr_age(self):
@@ -388,6 +581,40 @@ class Animal(models.Model):
return reverse('animal-detail', args=[str(self.id)])
class DistanceChoices(models.IntegerChoices):
TWENTY = 20, '20 km'
FIFTY = 50, '50 km'
ONE_HUNDRED = 100, '100 km'
TWO_HUNDRED = 200, '200 km'
FIVE_HUNDRED = 500, '500 km'
class SearchSubscription(models.Model):
"""
SearchSubscriptions allow a user to get a notification when a new AdoptionNotice is added that matches their Search
criteria. Search criteria are location, SexChoicesWithAll and distance
Process:
- User performs a normal search
- User clicks Button "Subscribe to this Search"
- SearchSubscription is added to database
- On new AdoptionNotice: Check all existing SearchSubscriptions for matches
- For matches: Send notification to user of the SearchSubscription
"""
owner = models.ForeignKey(User, on_delete=models.CASCADE)
location = models.ForeignKey(Location, on_delete=models.PROTECT, null=True)
sex = models.CharField(max_length=20, choices=SexChoicesWithAll.choices)
max_distance = models.IntegerField(choices=DistanceChoices.choices, null=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
if self.location and self.max_distance:
return f"{self.owner}: [{SexChoicesWithAll(self.sex).label}] {self.max_distance}km - {self.location}"
else:
return f"{self.owner}: [{SexChoicesWithAll(self.sex).label}]"
class Rule(models.Model):
"""
Class to store rules
@@ -399,6 +626,8 @@ class Rule(models.Model):
language = models.ForeignKey(Language, on_delete=models.PROTECT)
# Rule identifier allows to translate rules with the same identifier
rule_identifier = models.CharField(max_length=24)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
@@ -419,9 +648,10 @@ class Report(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, help_text=_('ID dieses reports'),
verbose_name=_('ID'))
status = models.CharField(max_length=30, choices=STATES)
reported_broken_rules = models.ManyToManyField(Rule)
user_comment = models.TextField(blank=True)
reported_broken_rules = models.ManyToManyField(Rule, verbose_name=_("Regeln gegen die verstoßen wurde"))
user_comment = models.TextField(blank=True, verbose_name=_("Kommentar/Zusätzliche Information"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"[{self.status}]: {self.user_comment:.20}"
@@ -468,20 +698,22 @@ class ModerationAction(models.Model):
}
action = models.CharField(max_length=30, choices=ACTIONS.items())
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
public_comment = models.TextField(blank=True)
# Only visible to moderator
private_comment = models.TextField(blank=True)
report = models.ForeignKey(Report, on_delete=models.CASCADE)
# TODO: Needs field for moderator that performed the action
def __str__(self):
return f"[{self.action}]: {self.public_comment}"
"""
Membership
"""
class TextTypeChoices(models.TextChoices):
DEDICATED = "dedicated", _("Fest zugeordnet")
MALE = "M", _("Männlich")
MALE_NEUTERED = "M_N", _("Männlich, kastriert")
FEMALE_NEUTERED = "F_N", _("Weiblich, kastriert")
INTER = "I", _("Intergeschlechtlich")
class Text(models.Model):
@@ -500,6 +732,17 @@ class Text(models.Model):
def __str__(self):
return f"{self.title} ({self.language})"
@staticmethod
def get_texts(text_codes, language, expandable_dict=None):
if expandable_dict is None:
expandable_dict = {}
for text_code in text_codes:
try:
expandable_dict[text_code] = Text.objects.get(text_code=text_code, language=language, )
except Text.DoesNotExist:
expandable_dict[text_code] = None
return expandable_dict
class Announcement(Text):
"""
@@ -507,7 +750,8 @@ class Announcement(Text):
"""
logged_in_only = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
publish_start_time = models.DateTimeField(verbose_name="Veröffentlichungszeitpunk")
updated_at = models.DateTimeField(auto_now=True)
publish_start_time = models.DateTimeField(verbose_name="Veröffentlichungszeitpunkt")
publish_end_time = models.DateTimeField(verbose_name="Veröffentlichungsende")
IMPORTANT = "important"
WARNING = "warning"
@@ -555,6 +799,7 @@ class Comment(models.Model):
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
text = models.TextField(verbose_name="Inhalt")
reply_to = models.ForeignKey("self", verbose_name="Antwort auf", blank=True, null=True, on_delete=models.CASCADE)
@@ -572,7 +817,9 @@ class Comment(models.Model):
class BaseNotification(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
title = models.CharField(max_length=100)
updated_at = models.DateTimeField(auto_now=True)
read_at = models.DateTimeField(blank=True, null=True, verbose_name=_("Gelesen am"))
title = models.CharField(max_length=100, verbose_name=_("Titel"))
text = models.TextField(verbose_name="Inhalt")
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
read = models.BooleanField(default=False)
@@ -583,20 +830,69 @@ class BaseNotification(models.Model):
def get_absolute_url(self):
self.user.get_notifications_url()
def mark_read(self):
self.read = True
self.read_at = timezone.now()
self.save()
class CommentNotification(BaseNotification):
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, verbose_name=_('Antwort'))
@property
def url(self):
print(f"URL: self.comment.get_absolute_url()")
return self.comment.get_absolute_url
class AdoptionNoticeNotification(BaseNotification):
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('Vermittlung'))
@property
def url(self):
return self.adoption_notice.get_absolute_url
class Subscriptions(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('Nutzer*in'))
adoption_notice = models.ForeignKey(AdoptionNotice, on_delete=models.CASCADE, verbose_name=_('AdoptionNotice'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.owner} - { self.adoption_notice }"
return f"{self.owner} - {self.adoption_notice}"
class Log(models.Model):
"""
Basic class that allows logging random entries for later inspection
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_("Nutzer*in"), blank=True, null=True)
action = models.CharField(max_length=255, verbose_name=_("Aktion"))
text = models.CharField(max_length=1000, verbose_name=_("Log text"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"[{self.action}] - {self.user} - {self.created_at.strftime('%H:%M:%S %d-%m-%Y ')}"
class Timestamp(models.Model):
"""
Class to store timestamps based on keys
"""
key = models.CharField(max_length=255, verbose_name=_("Schlüssel"), primary_key=True)
timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_("Zeitstempel"))
data = models.CharField(max_length=2000, blank=True, null=True)
def ___str__(self):
return f"[{self.key}] - {self.timestamp.strftime('%H:%M:%S %d-%m-%Y ')} - {self.data}"
class SpeciesSpecificURL(models.Model):
"""
Model that allows to specify a URL for a rescue organization where a certain species can be found
"""
species = models.ForeignKey(Species, on_delete=models.CASCADE, verbose_name=_("Tierart"))
rescues_organization = models.ForeignKey(RescueOrganization, on_delete=models.CASCADE,
verbose_name=_("Tierschutzorganisation"))
url = models.URLField(verbose_name=_("Tierartspezifische URL"))

View File

@@ -0,0 +1,37 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from fellchensammlung.models import BaseNotification, CommentNotification, User, TrustLevel
from .tasks import task_send_notification_email
from notfellchen.settings import host
from django.utils.translation import gettext_lazy as _
@receiver(post_save, sender=CommentNotification)
def comment_notification_receiver(sender, instance: BaseNotification, created: bool, **kwargs):
base_notification_receiver(sender, instance, created, **kwargs)
@receiver(post_save, sender=BaseNotification)
def base_notification_receiver(sender, instance: BaseNotification, created: bool, **kwargs):
if not created or not instance.user.email_notifications:
return
else:
task_send_notification_email.delay(instance.pk)
@receiver(post_save, sender=User)
def notification_new_user(sender, instance: User, created: bool, **kwargs):
NEWLINE = "\r\n"
if not created:
return
# Create Notification text
subject = _("Neuer User") + f": {instance.username}"
new_user_text = _("Es hat sich eine neue Person registriert.") + f"{NEWLINE}"
user_detail_text = _("Username") + f": {instance.username}{NEWLINE}" + _(
"E-Mail") + f": {instance.email}{NEWLINE}"
user_url = "https://" + host + instance.get_absolute_url()
link_text = f"Um alle Details zu sehen, geh bitte auf: {user_url}"
body_text = new_user_text + user_detail_text + link_text
for moderator in User.objects.filter(trust_level__gt=TrustLevel.MODERATOR):
notification = BaseNotification.objects.create(title=subject, text=body_text, user=moderator)
notification.save()

View File

@@ -1,3 +1,7 @@
/***************/
/* MAIN COLORS */
/***************/
:root {
--primary-light-one: #5daa68;
--primary-light-two: #4a9455;
@@ -19,6 +23,9 @@
--shadow-three: var(--primary-dark-one);
}
/**************************/
/* TAG SETTINGS (GENERAL) */
/**************************/
html, body {
margin: 0;
height: 100%;
@@ -30,17 +37,49 @@ body {
}
.content-box {
margin: 20px;
a {
color: inherit;
}
h1, h2, h3 {
margin-bottom: 5px;
margin-top: 5px;
}
table {
width: 100%;
}
a {
text-decoration: none;
color: inherit;
@media screen and (max-width: 600px) {
.responsive thead {
visibility: hidden;
height: 0;
position: absolute;
}
.responsive tr {
display: block;
}
.responsive td {
border: 1px solid;
border-bottom: none;
display: block;
font-size: .8em;
text-align: right;
width: 100%;
}
.responsive td::before {
content: attr(data-label);
float: left;
font-weight: bold;
text-transform: uppercase;
}
.responsive td:last-child {
border-bottom: 1px solid;
}
}
table {
@@ -62,7 +101,7 @@ td {
padding: 5px;
}
th {
thead td {
border: 3px solid black;
border-collapse: collapse;
padding: 8px;
@@ -79,67 +118,72 @@ h1, h2 {
box-sizing: border-box;
}
.header {
overflow: hidden;
background-color: var(--background-two);
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
}
.nav-link {
color: var(--text-one);
text-align: center;
text-decoration: none;
border-radius: 4px;
}
.header a, .header form {
float: left;
padding: 5px 12px 5px 12px;
line-height: 25px;
}
.header a:hover {
background-color: var(--highlight-one);
color: var(--highlight-one-text);
}
.header a.active {
background-color: dodgerblue;
color: white;
}
select, .button {
textarea {
border-radius: 10px;
width: 100%;
border: none;
border-radius: 4px;
opacity: 1;
background-color: var(--secondary-light-one);
margin: 5px;
}
.header-right select.option {
color: #000;
background-color: var(--highlight-one);
border: 1px;
}
.header-right {
float: right;
/**************/
/* CONTAINERS */
/**************/
.container-cards {
display: flex;
border-radius: 0px 0px 15px 15px;
background-color: var(--highlight-two);
color: var(--highlight-one-text);
padding: 5px 5px 0px 5px;
height: 67px;
flex-wrap: wrap;
width: 100%;
}
.container-cards h1,
.container-cards h2 {
width: 100%; /* Make sure heading fills complete line */
}
.card {
flex: 1 25%;
margin: 10px;
border-radius: 8px;
padding: 8px;
background: var(--background-three);
color: var(--text-two);
}
.container-edit-buttons {
display: flex;
flex-wrap: wrap;
.btn {
margin: 5px;
}
}
.spaced > * {
margin: 10px;
}
/*******************************/
/* PARTIAL SPECIFIC CONTAINERS */
/*******************************/
.detail-animal-header {
border-radius: 5px;
display: flex;
align-items: center;
justify-content: space-between;
}
@media screen and (max-width: 800px) {
.detail-animal-header {
display: block;
}
}
.profile-card {
display: flex;
border-radius: 0px 0px 8px 8px;
background-color: var(--highlight-two);
color: var(--highlight-one-text);
align-items: center;
.btn2 {
height: 40px;
@@ -156,22 +200,37 @@ select, .button {
}
}
@media screen and (max-width: 500px) {
.header a {
float: none;
display: block;
text-align: left;
}
.container-comment-form {
width: 80%;
color: var(--text-one);
.header-right {
float: none;
b {
text-shadow: 2px 2px var(--shadow-one);
}
}
.logo img {
height: 40px;
/*************/
/* Modifiers */
/*************/
/* Used to enlargen cards */
.full-width {
width: 100%;
flex: none;
}
/***********/
/* BUTTONS */
/***********/
select, .button {
width: 100%;
border: none;
border-radius: 4px;
opacity: 1;
background-color: var(--secondary-light-one);
}
.btn {
background-color: var(--primary-light-one);
@@ -180,9 +239,14 @@ select, .button {
border-radius: 8px;
border: none;
font-weight: bold;
display: block;
}
.btn2 {
a.btn, a.btn2, a.nav-link {
text-decoration: none;
}
.btn2, .btn3 {
background-color: var(--secondary-light-one);
color: var(--primary-dark-one);
padding: 8px;
@@ -191,6 +255,318 @@ select, .button {
margin: 5px;
}
.btn3 {
border: 1px solid black;
}
.btn-small {
font-size: medium;
padding: 6px;
}
.checkmark {
display: inline-block;
position: relative;
left: 0.2rem;
bottom: 0.075rem;
background-color: var(--primary-light-one);
color: var(--secondary-light-one);
border-radius: 0.5rem;
width: 1.5rem;
height: 1.5rem;
text-align: center;
}
.switch {
cursor: pointer;
display: inline-block;
}
.toggle-switch {
display: inline-block;
background: #ccc;
border-radius: 16px;
width: 58px;
height: 32px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
}
.toggle-switch:before, .toggle-switch:after {
content: "";
}
.toggle-switch:before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
width: 24px;
height: 24px;
position: absolute;
top: 4px;
left: 4px;
transition: left 0.25s;
}
.toggle:hover .toggle-switch:before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}
.checked + .toggle-switch {
background: #56c080;
}
.checked + .toggle-switch:before {
left: 30px;
}
.toggle-checkbox {
position: absolute;
visibility: hidden;
}
.slider-label {
margin-left: 5px;
position: relative;
top: 2px;
}
/* Refactor tooltip based on https://luigicavalieri.com/blog/css-tooltip-appearing-from-any-direction/ to allow different directions */
.tooltip {
display: inline-flex;
justify-content: center;
position: relative;
}
.tooltip:hover .tooltiptext {
display: flex;
opacity: 1;
visibility: visible;
}
.tooltip .tooltiptext {
border-radius: 4px;
bottom: calc(100% + 0.6em + 2px);
box-shadow: 0px 2px 4px #07172258;
background-color: var(--primary-dark-one);
color: var(--secondary-light-one);
font-size: 0.68rem;
justify-content: center;
line-height: 1.35em;
padding: 0.5em 0.7em;
position: absolute;
text-align: center;
width: 7rem;
z-index: 1;
display: flex;
opacity: 0;
transition: all 0.3s ease-in;
visibility: hidden;
}
.tooltip .tooltiptext::before {
border-width: 0.6em 0.8em 0;
border-color: transparent;
border-top-color: var(--primary-dark-one);
content: "";
display: block;
border-style: solid;
position: absolute;
top: 100%;
}
/* Makes the tooltip fly from above */
.tooltip.top .tooltiptext {
margin-bottom: 8px;
}
.tooltip.top:hover .tooltiptext {
margin-bottom: 0;
}
/* Make adjustments for bottom */
.tooltip.bottom .tooltiptext {
top: calc(100% + 0.6em + 2px);
margin-top: 8px;
}
.tooltip.bottom:hover .tooltiptext {
margin-top: 0;
}
.tooltip.bottom .tooltiptext::before {
transform: rotate(180deg);
/* 100% of the height of .tooltip */
bottom: 100%;
}
.tooltip:not(.top) .tooltiptext {
bottom: auto;
}
.tooltip:not(.top) .tooltiptext::before {
top: auto;
}
/*********************/
/* UNIQUE COMPONENTS */
/*********************/
.content-box {
margin: 20px;
}
.header {
overflow: hidden;
background-color: var(--background-two);
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
/* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
color: #FFF;
height: 50px;
padding: 1em;
}
#main-menu {
order: -1;
}
.menu {
display: flex;
flex-direction: row;
list-style-type: none;
margin: 0;
padding: 0;
}
.menu > li {
margin: 0 1rem;
overflow: hidden;
}
.menu-button-container {
display: none;
height: 100%;
width: 30px;
cursor: pointer;
flex-direction: column;
justify-content: center;
align-items: center;
background: #4ab457;
padding: 20px;
border-radius: 8px;
}
#menu-toggle {
display: none;
}
.menu-button,
.menu-button::before,
.menu-button::after {
display: block;
background-color: #fff;
position: absolute;
height: 4px;
width: 30px;
transition: transform 400ms cubic-bezier(0.23, 1, 0.32, 1);
border-radius: 2px;
}
.menu-button::before {
content: '';
margin-top: -8px;
}
.menu-button::after {
content: '';
margin-top: 8px;
}
#menu-toggle:checked + .menu-button-container .menu-button::before {
margin-top: 0px;
transform: rotate(405deg);
}
#menu-toggle:checked + .menu-button-container .menu-button {
background: rgba(255, 255, 255, 0);
}
#menu-toggle:checked + .menu-button-container .menu-button::after {
margin-top: 0px;
transform: rotate(-405deg);
}
@media (max-width: 700px) {
.menu-button-container {
display: flex;
}
.menu {
position: absolute;
top: 0;
margin-top: 50px;
left: 0;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
}
#menu-toggle ~ nav .menu li {
height: 0;
margin: 0;
padding: 0;
border: 0;
transition: height 400ms cubic-bezier(0.23, 1, 0.32, 1);
}
#menu-toggle:checked ~ nav .menu li {
height: 3em;
padding: 1em;
transition: height 400ms cubic-bezier(0.23, 1, 0.32, 1);
}
.header {
border-radius: 0;
}
.menu > li {
display: flex;
justify-content: center;
margin: 0;
padding: 0.5em 0;
width: 100%;
color: white;
background-color: var(--background-two);
}
.menu > li:not(:last-child) {
border-bottom: 1px solid #444;
}
#header-sign-out, #header-change-language {
display: none;
}
}
.logo img {
height: 40px;
}
.form-button, .link-button a:link, .link-button a:visited {
background-color: #4ba3cd;
color: white;
@@ -203,14 +579,6 @@ select, .button {
border: none;
}
.container-edit-buttons {
display: flex;
flex-wrap: wrap;
.btn {
margin: 5px;
}
}
.form-button:hover, .link-button a:hover, .link-button a:active {
background-color: #4090b6;
@@ -326,18 +694,6 @@ select, .button {
}
}
.detail-animal-header {
border-radius: 5px;
display: flex;
align-items: center;
justify-content: space-between;
}
@media screen and (max-width: 800px) {
.detail-animal-header {
display: block;
}
}
.tag {
border: black 1px solid;
@@ -355,12 +711,6 @@ select, .button {
}
.container-cards {
display: flex;
flex-wrap: wrap;
}
.photos {
display: flex;
flex-wrap: wrap;
@@ -376,14 +726,6 @@ select, .button {
border-radius: 10%;
}
.card {
flex: 1 25%;
margin: 10px;
border-radius: 8px;
padding: 5px;
background: var(--background-three);
color: var(--text-two);
}
.card h1 {
color: var(--text-three);
@@ -403,12 +745,22 @@ select, .button {
.header-card-adoption-notice {
display: flex;
justify-content: space-evenly;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.search-subscription-header {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
h3 {
width: 80%;
}
}
.table-adoption-notice-info {
margin-top: 10px;
}
@@ -434,7 +786,6 @@ select, .button {
.btn-notification {
display: inline-block;
position: relative;
padding: 0;
}
/* Make the badge float in the top right corner of the button */
@@ -454,15 +805,30 @@ select, .button {
.adoption-card-report-link, .notification-card-mark-read {
margin-left: auto;
font-size: 2rem;
padding: 10px;
}
.adoption-card-report-link {
margin-right: 12px;
}
.notification-card-mark-read {
display: inline;
}
.heading-card-adoption-notice {
.inline-container {
display: inline-block;
}
.inline-container > * {
vertical-align: middle;
}
h2.heading-card-adoption-notice {
font-size: 2rem;
line-height: 2rem;
word-wrap: anywhere;
width: 80%;
}
.tags {
@@ -477,17 +843,15 @@ select, .button {
}
.detail-adoption-notice-header h1 {
width: 50%;
display: inline-block;
}
.detail-adoption-notice-header a {
display: inline-block;
float: right;
}
@media (max-width: 920px) {
.detail-adoption-notice-header h1 {
.detail-adoption-notice-header .inline-container {
width: 100%;
}
@@ -505,7 +869,7 @@ select, .button {
padding: 5px;
}
.comment, .notification {
.comment, .notification, .search-subscription {
flex: 1 100%;
margin: 10px;
border-radius: 8px;
@@ -514,20 +878,6 @@ select, .button {
color: var(--text-two);
}
.container-comment-form {
width: 80%;
color: var(--text-one);
b {
text-shadow: 2px 2px var(--shadow-one);
}
}
textarea {
border-radius: 10px;
width: 100%;
margin: 5px;
}
.form-comments {
.btn {
@@ -535,7 +885,16 @@ textarea {
}
}
.announcement {
.announcement-header {
font-size: 1.2rem;
margin: 0px;
padding: 0px;
color: var(--text-two);
text-shadow: none;
font-weight: bold;
}
div.announcement {
flex: 1 100%;
margin: 10px;
border-radius: 8px;
@@ -543,15 +902,60 @@ textarea {
background: var(--background-three);
color: var(--text-two);
h1 {
font-size: 1.2rem;
margin: 0px;
padding: 0px;
color: var(--text-two);
text-shadow: none;
}
}
.form-search {
select, input {
background-color: var(--primary-light-one);
color: var(--text-one);
border-radius: 3px;
border: none;
}
}
.half {
width: 49%;
}
#results {
margin-top: 10px;
list-style-type: none;
padding: 0;
}
.result-item {
padding: 8px;
margin: 4px 0;
background-color: #ddd1a5;
cursor: pointer;
border-radius: 8px;
}
.result-item:hover {
background-color: #ede1b5;
}
.label {
border-radius: 8px;
padding: 4px;
color: #fff;
}
.active-adoption {
background-color: #4a9455;
}
.inactive-adoption {
background-color: #000;
}
/************************/
/* GENERAL HIGHLIGHTING */
/************************/
.important {
border: #e01137 4px solid;
}
@@ -564,16 +968,19 @@ textarea {
border: rgba(17, 58, 224, 0.51) 4px solid;
}
.form-search {
select, input {
background-color: var(--primary-light-one);
color: var(--text-one);
border-radius: 3px;
border: none;
}
.error {
color: #370707;
font-weight: bold;
}
.error::before {
content: "⚠️";
}
/*******/
/* MAP */
/*******/
.marker {
background-image: url('../img/logo_transparent.png');
background-size: cover;
@@ -582,6 +989,11 @@ textarea {
cursor: pointer;
}
.animal-shelter-marker {
background-image: url('../img/animal_shelter.png');
!important;
}
.maplibregl-popup {
max-width: 600px !important;
}
@@ -594,4 +1006,4 @@ textarea {
.map-in-content #map {
height: 500px;
width: 90%;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,21 @@
function ifdef(variable, prefix = "", suffix = "") {
if (variable !== undefined) {
return prefix + variable + suffix;
} else {
return "";
}
}
function geojson_to_summary(location) {
if (ifdef(location.properties.name) !== "") {
return location.properties.name + ifdef(location.properties.city, " (", ")");
} else {
return ifdef(location.properties.street, "", ifdef(location.properties.housenumber, " ","")) + ifdef(location.properties.city, ", ", "") + ifdef(location.properties.countrycode, ", ", "")
}
}
function geojson_to_searchable_string(location) {
return ifdef(location.properties.name, "", ", ") + ifdef(location.properties.street, "", ifdef(location.properties.housenumber, " ",", ")) + ifdef(location.properties.city, "", ", ") + ifdef(location.properties.country, "", "")
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,59 @@
import logging
from celery.app import shared_task
from django.utils import timezone
from notfellchen.celery import app as celery_app
from .mail import send_notification_email
from .tools.admin import clean_locations, deactivate_unchecked_adoption_notices, deactivate_404_adoption_notices
from .tools.misc import healthcheck_ok
from .models import Location, AdoptionNotice, Timestamp
from .tools.notifications import notify_of_AN_to_be_checked
from .tools.search import notify_search_subscribers
def set_timestamp(key: str):
try:
ts = Timestamp.objects.get(key=key)
ts.timestamp = timezone.now()
ts.save()
except Timestamp.DoesNotExist:
Timestamp.objects.create(key=key, timestamp=timezone.now())
@celery_app.task(name="admin.clean_locations")
def task_clean_locations():
clean_locations()
set_timestamp("task_clean_locations")
@celery_app.task(name="admin.daily_unchecked_deactivation")
def task_deactivate_unchecked():
deactivate_unchecked_adoption_notices()
set_timestamp("task_daily_unchecked_deactivation")
@celery_app.task(name="admin.deactivate_404_adoption_notices")
def task_deactivate_unchecked():
deactivate_404_adoption_notices()
set_timestamp("task_deactivate_404_adoption_notices")
@celery_app.task(name="commit.post_an_save")
def post_adoption_notice_save(pk):
instance = AdoptionNotice.objects.get(pk=pk)
Location.add_location_to_object(instance)
set_timestamp("add_adoption_notice_location")
logging.info(f"Location was added to Adoption notice {pk}")
notify_search_subscribers(instance, only_if_active=True)
notify_of_AN_to_be_checked(instance)
@celery_app.task(name="tools.healthcheck")
def task_healthcheck():
healthcheck_ok()
set_timestamp("task_healthcheck")
@shared_task
def task_send_notification_email(notification_pk):
send_notification_email(notification_pk)

View File

@@ -2,22 +2,54 @@
{% load i18n %}
{% load custom_tags %}
{% block title %}<title>{% translate "Über uns und Regeln" %}</title>{% endblock %}
{% block content %}
<h1>{% translate "Regeln" %}</h1>
{% if about_us %}
<div class="card">
<h1>{{ about_us.title }}</h1>
<p>
{{ about_us.content | render_markdown }}
</p>
</div>
{% endif %}
<h2>{% translate "Regeln" %}</h2>
{% include "fellchensammlung/lists/list-rules.html" %}
{% if faq %}
<div class="card">
<h2>{{ faq.title }}</h2>
<p>
{{ faq.content | render_markdown }}
</p>
</div>
{% endif %}
{% if privacy_statement %}
<h1>{{ privacy_statement.title }}</h1>
{{ privacy_statement.content | render_markdown }}
<div class="card">
<h2>{{ privacy_statement.title }}</h2>
<p>
{{ privacy_statement.content | render_markdown }}
</p>
</div>
{% endif %}
{% if terms_of_service %}
<h1>{{ terms_of_service.title }}</h1>
{{ terms_of_service.content | render_markdown }}
<div class="card">
<h2>{{ terms_of_service.title }}</h2>
<p>
{{ terms_of_service.content | render_markdown }}
</p>
</div>
{% endif %}
{% if imprint %}
<h1>{{ imprint.title }}</h1>
{{ imprint.content | render_markdown }}
<div class="card">
<h2>{{ imprint.title }}</h2>
<p>
{{ imprint.content | render_markdown }}
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% block title %}<title>{% translate "Tierschutzorganisationen" %}</title>{% endblock %}
{% block content %}
<div class="container-cards">
<div class="card">
{% include "fellchensammlung/partials/partial-map.html" %}
</div>
</div>
{% include "fellchensammlung/lists/list-animal-shelters.html" %}
{% endblock %}

View File

@@ -1,10 +1,12 @@
{% load custom_tags %}
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
{% block title %}<title>Notfellchen</title>{% endblock %}
{% block title %}{% endblock %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% translate "Farbratten aus dem Tierschutz finden und adoptieren" %}">
<!-- Add additional CSS in static file -->
{% load static %}
<link rel="stylesheet" href="{% static 'fellchensammlung/css/styles.css' %}">
@@ -12,6 +14,8 @@
<link href="{% static 'fontawesomefree/css/brands.css' %}" rel="stylesheet" type="text/css">
<link href="{% static 'fontawesomefree/css/solid.css' %}" rel="stylesheet" type="text/css">
<script src="{% static 'fellchensammlung/js/custom.js' %}"></script>
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'fellchensammlung/favicon/apple-touch-icon.png' %}">
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'fellchensammlung/favicon/favicon-32x32.png' %}">
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'fellchensammlung/favicon/favicon-16x16.png' %}">

View File

@@ -0,0 +1,69 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load custom_tags %}
{% load i18n %}
{% block title %}<title>{{ org.name }}</title>{% endblock %}
{% block content %}
<div class="container-cards">
<div class="card half">
<h1>{{ org.name }}</h1>
<b><i class="fa-solid fa-location-dot"></i></b>
{% if org.location %}
{{ org.location.str_hr }}
{% else %}
{{ org.location_string }}
{% endif %}
<p>{{ org.description | render_markdown }}</p>
<table class="responsive">
<thead>
<tr>
{% if org.website %}
<td>{% translate "Website" %}</td>
{% endif %}
{% if org.phone_number %}
<td>{% translate "Telefonnummer" %}</td>
{% endif %}
{% if org.email %}
<td>{% translate "E-Mail" %}</td>
{% endif %}
</tr>
</thead>
<tr>
{% if org.website %}
<td data-label="{% trans 'Website' %} ">
{{ org.website }}
</td>
{% endif %}
{% if org.phone_number %}
<td data-label="{% trans 'Telefonnummer' %}">
{{ org.phone_number }}
</td>
{% endif %}
{% if org.email %}
<td data-label="{% trans 'E-Mail' %}">
{{ org.email }}
</td>
{% endif %}
</tr>
</table>
</div>
<div class="card half">
{% include "fellchensammlung/partials/partial-map.html" %}
</div>
</div>
<h2>{% translate 'Vermittlungen der Organisation' %}</h2>
<div class="container-cards">
{% if org.adoption_notices %}
{% for adoption_notice in org.adoption_notices %}
{% include "fellchensammlung/partials/partial-adoption-notice-minimal.html" %}
{% endfor %}
{% else %}
<p>{% translate "Keine Vermittlungen gefunden." %}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,24 +1,83 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% block title %}<title>{{ user.get_full_name }}</title>{% endblock %}
{% block content %}
<h1>{{ user.get_full_name }}</h1>
<div class="spaced">
<p><strong>{% translate "Username" %}:</strong> {{ user.username }}</p>
<p><strong>{% translate "E-Mail" %}:</strong> {{ user.email }}</p>
<div class="container-cards">
<h2>{% trans 'Daten' %}</h2>
<div class="card">
<p><strong>{% translate "Username" %}:</strong> {{ user.username }}</p>
<p><strong>{% translate "E-Mail" %}:</strong> {{ user.email }}</p>
</div>
</div>
{% if user.preferred_language %}
<p><strong>{% translate "Sprache" %}:</strong> {{ user.preferred_language }}</p>
{% else %}
<p>{% translate "Keine bevorzugte Sprache gesetzt." %}</p>
{% endif %}
{% if user.id is request.user.id %}
<div class="container-cards">
<h2>{% trans 'Profil verwalten' %}</h2>
<div class="container-comment-form">
<p>
<a class="btn2" href="{% url 'password_change' %}">{% translate "Change password" %}</a>
<a class="btn2" href="{% url 'user-me-export' %}">{% translate "Daten exportieren" %}</a>
</p>
</div>
</div>
<h2>{% translate 'Benachrichtigungen' %}</h2>
{% include "fellchensammlung/lists/list-notifications.html" %}
<h2>{% translate 'Meine Vermittlungen' %}</h2>
{% include "fellchensammlung/lists/list-adoption-notices.html" %}
{% if user.id is request.user.id %}
<div class="detail-animal-header"><h2>{% trans 'Einstellungen' %}</h2></div>
<div class="container-cards">
<form class="card" action="" method="POST">
{% csrf_token %}
{% if user.email_notifications %}
<label class="toggle">
<input type="submit" class="toggle-checkbox checked" name="toggle_email_notifications">
<div class="toggle-switch round "></div>
<span class="slider-label">
{% translate 'E-Mail Benachrichtigungen' %}
</span>
</label>
{% else %}
<label class="toggle">
<input type="submit" class="toggle-checkbox" name="toggle_email_notifications">
<div class="toggle-switch round"></div>
<span class="slider-label">
{% translate 'E-Mail Benachrichtigungen' %}
</span>
</label>
{% endif %}
</form>
<div class="card">
{% if token %}
<form action="" method="POST">
{% csrf_token %}
<p class="text-muted"><strong>{% translate "API token:" %}</strong> {{ token }}</p>
<input class="btn" type="submit" name="delete_token"
value={% translate "Delete API token" %}>
</form>
{% else %}
<p>{% translate "Kein API-Token vorhanden." %}</p>
<form action="" method="POST">
{% csrf_token %}
<input class="btn" type="submit" name="create_token"
value={% translate "Create API token" %}>
</form>
{% endif %}
</div>
</div>
{% endif %}
<h2>{% translate 'Benachrichtigungen' %}</h2>
{% include "fellchensammlung/lists/list-notifications.html" %}
<h2>{% translate 'Abonnierte Suchen' %}</h2>
{% include "fellchensammlung/lists/list-search-subscriptions.html" %}
<h2>{% translate 'Meine Vermittlungen' %}</h2>
{% include "fellchensammlung/lists/list-adoption-notices.html" %}
{% endif %}
</div>
{% endblock %}

View File

@@ -2,9 +2,43 @@
{% load custom_tags %}
{% load i18n %}
{% block title %}<title>{{ adoption_notice.name }}</title>{% endblock %}
{% block content %}
<div class="detail-adoption-notice-header">
<h1 class="detail-adoption-notice-header">{{ adoption_notice.name }}</h1>
<div class="inline-container">
<h1>{{ adoption_notice.name }}</h1>
{% if not is_subscribed %}
<div class="tooltip bottom">
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell"></i></button>
</form>
<span class="tooltiptext">
{% translate 'Abonniere diese Vermittlung um bei Kommentaren oder Statusänderungen benachrichtigt zu werden' %}
</span>
</div>
{% else %}
<div class="tooltip bottom">
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="adoption_notice_id" value="{{ adoption_notice.pk }}">
<button class="btn2" type="submit" id="submit"><i class="fa-solid fa-bell-slash"></i></button>
</form>
<span class="tooltiptext">
{% translate 'Deabonnieren. Du bekommst keine Benachrichtigungen zu dieser Vermittlung mehr' %}
</span>
</div>
{% endif %}
{% if adoption_notice.is_active %}
<span id="submit" class="label active-adoption" style=>{% trans 'Aktive Vermittlung' %}</span>
{% else %}
<span id="submit" class="label inactive-adoption" style=>{% trans 'Vermittlung inaktiv' %}</span>
{% endif %}
</div>
{% if has_edit_permission %}
<a class="btn2"
href="{% url 'adoption-notice-add-photo' adoption_notice_id=adoption_notice.pk %}">{% translate 'Foto hinzufügen' %}</a>
@@ -13,27 +47,61 @@
{% endif %}
</div>
<div class="table-adoption-notice-info">
<table>
<table class="responsive">
<thead>
<tr>
<th>{% translate "Ort" %}</th>
<th>{% translate "Suchen seit" %}</th>
<th>{% translate "Weitere Informationen" %}</th>
<td>{% translate "Ort" %}</td>
{% if adoption_notice.organization %}
<td>{% translate "Organisation" %}</td>
{% endif %}
<td>{% translate "Suchen seit" %}</td>
<td>{% translate "Zuletzt aktualisiert" %}</td>
<td>{% translate "Weitere Informationen" %}</td>
</tr>
</thead>
<tr>
<td>
<td data-label="{% trans 'Ort' %} ">
{% if adoption_notice.location %}
{{ adoption_notice.location }}
{% else %}
{{ adoption_notice.location_string }}
{% endif %}
</td>
{% if adoption_notice.organization %}
<td data-label="{% trans 'Organisation' %}">
<div>
<a href="{{ adoption_notice.organization.get_absolute_url }}">{{ adoption_notice.organization }}</a>
{% if adoption_notice.organization.trusted %}
<div class="tooltip top">
<div class="checkmark"><i class="fa-solid fa-check"></i></div>
<span class="tooltiptext">
{% translate 'Diese Organisation kennt sich mit Ratten aus und achtet auf gute Abgabebedingungen' %}
</span>
</div>
{% endif %}
</div>
</td>
<td>{{ adoption_notice.searching_since }}</td>
{% if adoption_notice.further_information %}
<td>{{ adoption_notice.link_to_more_information | safe }}</td>
{% else %}
<td>-</td>
{% endif %}
<td data-label="{% trans 'Suchen seit' %}">{{ adoption_notice.searching_since }}</td>
<td data-label="{% trans 'Zuletzt aktualisiert' %}">
{{ adoption_notice.last_checked_hr }}
</td>
<td data-label="{% trans 'Weitere Informationen' %}">
{% if adoption_notice.further_information %}
<form method="get" action="{% url 'external-site' %}">
<input type="hidden" name="url" value="{{ adoption_notice.further_information }}">
<button class="btn" type="submit" id="submit">
{{ adoption_notice.further_information | domain }} <i
class="fa-solid fa-arrow-up-right-from-square"></i>
</button>
</form>
{% else %}
-
{% endif %}
</td>
</tr>
</table>
</div>

View File

@@ -2,6 +2,8 @@
{% load custom_tags %}
{% load i18n %}
{% block title %}<title>{{ animal.name }}</title>{% endblock %}
{% block content %}
{% include "fellchensammlung/details/detail-animal-partial.html" %}
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% load custom_tags %}
{% block title %}<title>{% translate "403 Forbidden" %}</title>{% endblock %}
{% block content %}
<h1>403 Forbidden</h1>
<p>
{% blocktranslate %}
Diese Aktion ist dir nicht erlaubt. Logge dich ein oder nutze einen anderen Account. Wenn du denkst, dass hier
ein Fehler vorliegt, kontaktiere das Team!
{% endblocktranslate %}
</p>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% load custom_tags %}
{% block content %}
<div class="card">
{% if external_site_warning %}
{{ external_site_warning.content | render_markdown }}
{% else %}
{% blocktranslate %}
<p>Achtung du verlässt notfellchen.org</p>
{% endblocktranslate %}
{% endif %}
<a href="{{ url }}" class="btn button">{% translate "Weiter" %}</a>
</div>
{% endblock content %}

View File

@@ -8,9 +8,6 @@
<option value="{{ language.0 }}" {% if language.0 == LANGUAGE_CODE_CURRENT %} selected{% endif %}>
{{ language.0|language_name_local }}
</option>
<!--<option value="{{ language.0 }}" {% if language.0 == LANGUAGE_CODE %} selected{% endif %}>
{{ language.0|language_name_local }} ({{ language.0 }})
</option>-->
{% endfor %}
</select>
<!--<input type="submit" value={% translate "change" %}>-->

View File

@@ -9,7 +9,7 @@
Lade hier ein Foto hoch - wähle den Titel wie du willst und mach bitte eine Bildbeschreibung,
damit die Fotos auch für blinde und sehbehinderte Personen zugänglich sind.
{% endblocktranslate %}
<p><a class="btn" href="https://www.dbsv.org/bildbeschreibung-4-regeln.html">{% translate 'Anleitung für Bildbeschreibungen' %}</a></p>
<p><a class="btn2" href="https://www.dbsv.org/bildbeschreibung-4-regeln.html">{% translate 'Anleitung für Bildbeschreibungen' %}</a></p>
</p>
<div class="container-form">
{% crispy form %}

View File

@@ -7,6 +7,6 @@
<form method = "post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button class="button-report" type="submit">{% translate "Melden" %}</button>
<button class="btn2" type="submit">{% translate "Melden" %}</button>
</form>
{% endblock %}

View File

@@ -2,6 +2,8 @@
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}<title>{% translate "Vermittlung hinzufügen" %}</title>{% endblock %}
{% block content %}
<h1>{% translate "Vermitteln" %}</h1>
<p>

View File

@@ -1,41 +1,57 @@
{% load static %}
{% load i18n %}
<div class="header">
<a href="{% url "index" %}" class="logo"><img src={% static 'fellchensammlung/img/logo_transparent.png' %}></a>
<nav id="nav" class="nav justify-content-center">
<a class="nav-link " href="{% url "search" %}"><i class="fas fa-search"></i> {% translate 'Suchen' %}</a>
<a class="nav-link " href="{% url "add-adoption" %}"><i
class="fas fa-feather"></i> {% translate 'Vermittlung hinzufügen' %}</a>
<a class="nav-link " href="{% url "about" %}"><i class="fas fa-info"></i> {% translate 'Über uns' %}</a>
<a class="nav-link " href="{% url "rss" %}"><i class="fa-solid fa-rss"></i> {% translate 'RSS' %}</a>
</nav>
<div class="header-right">
<div class="profile-card">
{% include "fellchensammlung/forms/change_language.html" %}
{% if user.is_authenticated %}
<div class="btn2 button_darken btn-notification">
<a href="{{ user.get_notifications_url }}">
<i class="fa fa-bell" aria-hidden="true"></i>
</a>
{% if user.get_num_unread_notifications > 0 %}
<span class="button__badge">{{ user.get_num_unread_notifications }}</span>
{% endif %}
</div>
<a class="btn2" href="{{ user.get_absolute_url }}"><i aria-hidden="true" class="fas fa-user"></i></a>
<form class="btn2 button_darken" action="{% url 'logout' %}" method="post">
{% csrf_token %}
<button class="button" type="submit"><i aria-hidden="true" class="fas fa-sign-out"></i></button>
</form>
{% else %}
<a class="btn2" href="{% url "django_registration_register" %}">{% translate "Registrieren" %}</a>
<a class="btn2" href="{% url "login" %}"><i class="fa fa-sign-in" aria-hidden="true"></i></a>
{% endif %}
</div>
<section class="header">
<div>
<a href="{% url "index" %}" class="logo"><img src={% static 'fellchensammlung/img/logo_transparent.png' %}></a>
</div>
</div>
<div class="profile-card">
<div id="header-change-language">
{% include "fellchensammlung/forms/change_language.html" %}
</div>
{% if user.is_authenticated %}
<div class="btn2 button_darken btn-notification">
<a href="{{ user.get_notifications_url }}">
<i class="fa fa-bell" aria-hidden="true"></i>
</a>
{% if user.get_num_unread_notifications > 0 %}
<span class="button__badge">{{ user.get_num_unread_notifications }}</span>
{% endif %}
</div>
<a class="btn2" href="{% url 'user-me' %}"><i aria-hidden="true" class="fas fa-user"></i></a>
<form class="btn2 button_darken" id="header-sign-out" action="{% url 'logout' %}" method="post">
{% csrf_token %}
<button class="button" type="submit"><i aria-hidden="true" class="fas fa-sign-out"></i></button>
</form>
{% else %}
<a class="btn2" href="{% url "django_registration_register" %}">{% translate "Registrieren" %}</a>
<a class="btn2" href="{% url "login" %}"><i class="fa fa-sign-in" aria-hidden="true"></i></a>
{% endif %}
<input id="menu-toggle" type="checkbox"/>
<label class='menu-button-container' for="menu-toggle">
<div class='menu-button'></div>
</label>
<nav id="main-menu">
<ul class="menu">
<li>
<a class="nav-link " href="{% url "search" %}">
<i class="fas fa-search"></i> {% translate 'Suchen' %}
</a>
</li>
<li><a class="nav-link " href="{% url "add-adoption" %}"><i
class="fas fa-feather"></i> {% translate 'Vermittlung hinzufügen' %}</a></li>
<li><a class="nav-link " href="{% url "about" %}"><i
class="fas fa-info"></i> {% translate 'Über uns' %}
</a>
</li>
<li><a class="nav-link " href="{% url "rss" %}"><i
class="fa-solid fa-rss"></i> {% translate 'RSS' %}
</a>
</li>
</ul>
</nav>
</div>
</section>

View File

@@ -2,6 +2,8 @@
{% load i18n %}
{% load custom_tags %}
{% block title %}<title>{% translate "Notfellchen - Farbratten aus dem Tierschutz adoptieren" %}</title>{% endblock %}
{% block content %}
{% for announcement in announcements %}
{% include "fellchensammlung/partials/partial-announcement.html" %}

View File

@@ -1,28 +1,51 @@
{% extends "fellchensammlung/base_generic.html" %}
{% load i18n %}
{% block title %}<title>{% translate "Instanz-Check" %}</title> {% endblock %}
{% block content %}
<div class="card">
<h1>{% translate "Instanz-Check" %}</h1>
{% if missing_texts|length > 0 %}
<h2>{% trans "Fehlende Texte" %}</h2>
<p>
<table>
<table>
<tr>
<th>{% translate "Text Code" %}</th>
<th>{% translate "Sprache" %}</th>
</tr>
{% for missing_text in missing_texts %}
<tr>
<th>{% translate "Text Code" %}</th>
<th>{% translate "Sprache" %}</th>
<td>{{ missing_text.0 }}</td>
<td>{{ missing_text.1 }}</td>
</tr>
{% for missing_text in missing_texts %}
<tr>
<td>{{ missing_text.0 }}</td>
<td>{{ missing_text.1 }}</td>
</tr>
{% endfor %}
</table>
{% endfor %}
</table>
</p>
{% else %}
<p>{% translate "Texte scheinen vollständig" %}</p>
{% endif %}
<h2>{% trans "Zeitstempel" %}</h2>
{% if timestamps|length > 0 %}
<p>
<table>
<tr>
<th>{% translate "Key" %}</th>
<th>{% translate "Zeitstempel" %}</th>
<th>{% translate "Daten" %}</th>
</tr>
{% for timestamp in timestamps %}
<tr>
<td>{{ timestamp.key }}</td>
<td>{{ timestamp.timestamp }}</td>
<td>{{ timestamp.data }}</td>
</tr>
{% endfor %}
</table>
</p>
{% else %}
<p>{% translate "Keine Zeitstempel geloggt." %}</p>
{% endif %}
<h2>{% translate "Nicht-lokalisierte Vermittlungen" %}</h2>
{% if number_not_geocoded_adoption_notices > 0 %}
<details>
@@ -55,6 +78,22 @@
<p>{{ number_not_geocoded_rescue_orgs }}/{{ number_of_rescue_orgs }}</p>
{% endif %}
<h2>{% translate "Nicht-geprüfte Vermittlungen" %}</h2>
{% if number_unchecked_ans > 0 %}
<details>
<summary>{{ number_unchecked_ans }}</summary>
<ul>
{% for unchecked_an in unchecked_ans %}
<li>
<a href="{{ unchecked_an.get_absolute_url }}">{{ unchecked_an.name }}</a>
</li>
{% endfor %}
</ul>
</details>
{% else %}
<p>{{ number_unchecked_ans }}</p>
{% endif %}
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="clean_locations">
@@ -62,5 +101,21 @@
<i class="fa-solid fa-broom"></i> {% translate "Erneut lokalisieren" %}
</button>
</form>
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="deactivate_unchecked_adoption_notices">
<button class="btn" type="submit" id="submit">
<i class="fa-solid fa-broom"></i> {% translate "Deaktiviere ungeprüfte Vermittlungen" %}
</button>
</form>
<form class="notification-card-mark-read" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="deactivate_404">
<button class="btn" type="submit" id="submit">
<i class="fa-solid fa-broom"></i> {% translate "Deaktiviere 404 Vermittlungen" %}
</button>
</form>
</div>
{% endblock content %}

Some files were not shown because too many files have changed in this diff Show More