Compare commits

...

235 Commits

Author SHA1 Message Date
45322df5b6 less url col space
Some checks failed
Build and Push Docker Image / build (push) Failing after 7m14s
2024-10-23 15:54:19 +01:00
078390ea81 style table update 2024-10-20 15:35:34 +01:00
5657d192b1 act-as-link disabled colour 2024-10-20 15:35:22 +01:00
c01b20c06a popover badges no longer have role="button"
Because they are not clickable.

The popover appears on focus and hover, not click, so indicating that it is a clickable element is not good.
2024-10-20 14:38:02 +01:00
49711ccad1 update sub table on submit 2024-10-20 14:37:06 +01:00
c89047ab13 add missing url 2024-10-20 14:36:56 +01:00
0fe99ae652 open related message style 2024-10-20 14:32:01 +01:00
6f8098eb12 popover badges 2024-10-19 23:19:27 +01:00
58648c8646 custom col sizes 2024-10-19 22:14:13 +01:00
b039db8517 class for spans acting as links (or buttons) 2024-10-19 22:14:06 +01:00
bf887db7cb bring changes 2024-10-18 23:17:38 +01:00
7d0f4d6f58 changes to dropdown rendering columns
Some checks failed
Build and Push Docker Image / build (push) Failing after 7m43s
2024-10-16 18:34:48 +01:00
38a6ada67e working on table columns dropdowns
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-10-15 21:43:42 +01:00
3dd5da4acd Update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-15 20:16:39 +01:00
1cd1199e28 message style safely deletes without setting null
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
will set the related subscription to use the default instead
2024-10-15 20:15:54 +01:00
9f18ae3f13 server setting controls
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-10-15 18:54:51 +01:00
7618eb3702 nudge sidebar spot left by 1px 2024-10-15 18:54:22 +01:00
dad27da365 update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-10-15 12:40:44 +01:00
f9fff0731c subscription - channels as required, non-empty field 2024-10-15 12:40:08 +01:00
97748dcaf6 init theme set on page load
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-15 12:24:34 +01:00
43ec85faf4 remove number of placeholders in sidebar 2024-10-15 12:16:25 +01:00
2d1ccb4a00 keep hover state when sidebar button's dropdown is showing
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-10-15 12:08:07 +01:00
233968634b functions for adding/removing spot classes to ".server-item"s 2024-10-15 12:07:44 +01:00
004e7b8b11 doc improvement 2024-10-15 12:07:08 +01:00
c278614e86 include next version 2024-10-15 12:06:54 +01:00
e0685620bb fix invalid select2 padding for icon space 2024-10-15 11:59:04 +01:00
87a84e248b add invalid style for select2 elements
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-15 11:55:26 +01:00
1593c3b370 big integer field for
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-10-15 00:21:44 +01:00
1573393311 compress static files
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-15 00:02:32 +01:00
0d37e6fd8e offline compress for prod
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-10-15 00:00:56 +01:00
ed7dab3147 add bootstrap to libsass paths
All checks were successful
Build and Push Docker Image / build (push) Successful in 8s
2024-10-14 23:56:31 +01:00
c5595ab823 subscription channels required
All checks were successful
Build and Push Docker Image / build (push) Successful in 8s
set blank=False, meaning 400 error will be raised if the channels field is missing or blank.
2024-10-14 23:29:16 +01:00
19c1657813 remove unused code & add more sidebar-loading items
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-14 23:27:56 +01:00
4bad3fb45b change server-item spot hover effect
now covers the full height, and the border radius of the server item creates straight edges to meet the expanded spot icon
2024-10-14 23:27:26 +01:00
8a563ca51e close theme dropmenu when sidebar collapses 2024-10-14 23:26:47 +01:00
2571630c41 .sidebar-pin-btn to .js-pinSidebar
Some checks failed
Build and Push Docker Image / build (push) Failing after 7m2s
2024-10-14 22:45:16 +01:00
7705ff1fcb set theme icon on sidebar 2024-10-14 22:45:00 +01:00
507b60e54e finish sidebar scss rewrite 2024-10-14 22:44:52 +01:00
c10c1ff511 remove bootstrap import from base file
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-10-14 11:13:53 +01:00
1d6e7c6884 tidy up sidebar scss
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
2024-10-14 11:13:38 +01:00
38b8184499 move to scss (incomplete)
All checks were successful
Build and Push Docker Image / build (push) Successful in 42s
incomplete, moving to scss
2024-10-13 23:36:42 +01:00
36a744159f add missing await statement
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-13 00:25:26 +01:00
0b50b2bb7f fixed unable to delete message styles #66
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
embarrassing mistake, accidentally used super().__init__(self) over super().delete()
2024-10-12 23:35:49 +01:00
306b24988f "spot" colours for sidebar items
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-12 23:13:01 +01:00
d7a30b9139 quicker transition speed for "is-not-operational" indicator 2024-10-12 22:48:13 +01:00
180a646267 remove select2 dependence on 'dropdownParent'
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
fixes an issue with dropdown options placement on smaller screens in modals.

Instead of 'dropdownParent', just set the z-index of the dropdown options to 9999 - above the modal.
2024-10-12 21:06:52 +01:00
a3279a4c91 completed #67 - "Cache" Loaded Channels
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-12 20:00:50 +01:00
b671c59a48 remove unused api functions
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
these are old and obsolete
2024-10-12 19:01:17 +01:00
082cf2989d rate limit handling for generate channels
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-12 19:00:53 +01:00
56e125c0bf invite link when channels load fails
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-10-12 18:26:14 +01:00
c8bae087f9 sidebar item · show active state when disabled
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-12 13:19:35 +01:00
eceee915fa disable sidebar while loading channels
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
and add new modal code for "non-operational" warning
2024-10-12 13:17:16 +01:00
3d4d5733b2 replace older modal code with new code 2024-10-12 13:15:25 +01:00
16e468f2f3 remove old custom modal code 2024-10-12 13:14:55 +01:00
65f12d9efa New confirm modal library
custom made
2024-10-12 13:04:15 +01:00
170c06ccab unpin sidebar when resizing to larger screen
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-11 19:37:58 +01:00
1073309646 update red "not operational" dot position and transition
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-11 19:09:59 +01:00
5949d72541 "bot not operational" indicator on sidebar items
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-11 18:54:32 +01:00
b2e75e6924 return serialized server data when generating servers 2024-10-11 18:52:16 +01:00
35c8e7582b is_bot_operational field on the Server model
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
True, False or None

indicates if the bot is a member of said server, and has sufficient permissions to perform it's job.

Starts as None (unknown) and becomes True or False when calling a server's `/generate-channels/` endpoint.
2024-10-11 17:24:49 +01:00
b324b6accb rate limit text and button change
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
Also added flex with justify and align both centred for server icons on the sidebar, so that the alt text (if no image exists) is vertically centred.
2024-10-11 17:23:07 +01:00
aff45f9ac5 add missing delete function for filters table & modal
All checks were successful
Build and Push Docker Image / build (push) Successful in 17s
2024-10-11 13:30:24 +01:00
4646c0a564 fix broken confirmation modal on subs table 2024-10-11 13:29:14 +01:00
2d3f4b6294 tidy up GuildsView
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
abstraction
comments
restructure of existing code ...
2024-10-11 11:49:26 +01:00
5f0251bd87 ChannelsView - 404 if server not found
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-11 10:35:03 +01:00
c4ce4b28a0 sidebar username overflow fix #64
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-11 10:16:26 +01:00
41211e61bd set roundness of retry button 2024-10-11 00:35:35 +01:00
e4e0264fd4 rate limit warning
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-11 00:27:22 +01:00
15ce3c1dbb prevent selecting the active server
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
instead, hide the sidebar on smaller screens, so that the user's input doesn't feel unresponsive.
2024-10-10 22:24:58 +01:00
49dfb59d71 don't disable search input & clear it on server change
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-10-10 22:14:49 +01:00
b8ff2ae303 nicer pin btn size, and disable sidebar toggle btns when pinned
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-10 16:44:20 +01:00
9ff2cdd1f1 pin sidebar btn 2024-10-10 16:37:21 +01:00
e07ea1f832 add missing margin below subUniqueRules control
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-10-10 16:37:06 +01:00
0b153d14bc improve server tabs on mobile screens
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-10 16:36:23 +01:00
048e293fb2 fix backdrop blur 2024-10-10 14:53:41 +01:00
ccb383fed8 backdrop for sidebar on smaller screens
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-10 09:33:23 +01:00
77c1787f65 remove unused code
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
code has been commented out for a while, and is unused.
2024-10-09 22:24:42 +01:00
1e5921f23a experimenting with different colours on the layout
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-09 22:18:27 +01:00
aefdcca369 increase z-index of reveal-sidebar-btn
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-09 21:36:16 +01:00
4dcd1f71fb animate sidebar & fix active server not highlighted
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-10-09 21:05:45 +01:00
3e4112c9cf servers in sidebar & collapsible sidebar
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
- functional server list in sidebar
- sidebar hides on smaller screens, can be revealed with dedicated button
2024-10-09 20:47:33 +01:00
068d395a4b sidebar functionality - except server list
Some checks failed
Build and Push Docker Image / build (push) Failing after 6m58s
2024-10-09 19:07:38 +01:00
9b5d4a9d99 moved clearValidation to openDataModal, so it's always called 2024-10-09 12:36:58 +01:00
204bc07739 add perfect scrollbar 2024-10-09 12:36:17 +01:00
9f76da4aa4 remake sidebar (non functional) 2024-10-09 12:35:51 +01:00
ef91169116 drop the navbar, for a better sidebar 2024-10-08 15:36:08 +01:00
118b0d4bdd form validation text
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-10-08 12:31:49 +01:00
09b910454e detail sub attributes
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
2024-10-07 21:15:42 +01:00
cfd7af3087 remove column size classes
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-10-07 20:39:35 +01:00
ded11af42b fix colour input, mutator detail fields
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-07 20:24:13 +01:00
64b4e95bd1 display embed colour and mutators
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-10-07 19:28:26 +01:00
7cd47165a9 confirmed next release version number
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-10-04 17:07:01 +01:00
0ffc189a8b Update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-10-04 16:58:45 +01:00
acc17cc9db fix - accidentally deleting channels from other servers
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
2024-10-03 17:10:23 +01:00
bd4ac4adcc give ID and name to message style select box 2024-10-03 15:49:07 +01:00
41771f40e0 improve non-editable style message 2024-10-03 15:48:52 +01:00
1d8be63834 prevent deleting auto_created=True styles 2024-10-03 15:48:38 +01:00
616428cf4d include style name on admin site 2024-10-03 15:48:07 +01:00
21e8088476 disable delete btn on refresh 2024-10-03 15:47:53 +01:00
ae23d0510d align channels detail column center 2024-10-03 12:54:16 +01:00
449dff8141 render badges array column & model submit use PUT
use PUT instead of PATCH
2024-10-03 12:50:11 +01:00
2da4018483 include channels details in subscription serializer 2024-10-03 12:48:54 +01:00
709b650f2e fix: JSON renderer not always casting when it should 2024-10-03 12:48:39 +01:00
f095cf717b disable admin interface
temp

it's annoying because it's the default, when I mostly use the BrowsableAPIRender view.
2024-10-03 12:48:05 +01:00
abbb4fbb62 fix rounding issue with large integers
this bug was the bane of my existence
2024-10-03 00:36:31 +01:00
183f888106 fix channels not showing as selected 2024-10-03 00:16:33 +01:00
760efda35a temp: include channels in sub table 2024-10-03 00:15:00 +01:00
fcde18b381 fix channels serializer 2024-10-03 00:14:50 +01:00
40f8c9f899 show channels as options 2024-10-02 23:35:01 +01:00
c8cd549ca8 store loaded channels 2024-10-02 23:34:51 +01:00
c6ad0b01e2 remove unused comment 2024-10-02 23:34:35 +01:00
9002eaf807 generate channels view 2024-10-02 23:32:45 +01:00
6127233c0f update discordchannel model 2024-10-02 23:32:30 +01:00
da8ed90686 remove old guild/channel views 2024-10-02 23:30:49 +01:00
199daf913e handle discord channels in serializers 2024-10-02 19:40:53 +01:00
14c2c1acca include name, icon and id in server tabs 2024-10-02 19:15:54 +01:00
443fb58d17 navbar cleanup 2024-10-02 17:44:19 +01:00
6a9ed93054 fix style issue when clicking active server in sidebar 2024-10-02 16:39:36 +01:00
f7ab26db35 toggle sub enable/disable switches when clicked 2024-10-02 16:36:25 +01:00
64b569fd4d handle error based on HTTP status 2024-10-02 16:16:50 +01:00
024a26325b discord-channel and style auto created field 2024-10-01 23:40:22 +01:00
1c4efd0da2 delete selected item functions 2024-10-01 23:39:53 +01:00
45aa4ee261 get table components 2024-10-01 23:39:24 +01:00
bf6ed0fd45 remove unused delete functions 2024-10-01 23:39:10 +01:00
b396134bea ok modal and modal icons 2024-10-01 23:38:55 +01:00
1eac879ea9 work on modal functionality 2024-10-01 23:38:35 +01:00
e6d0cea361 remove unused static files 2024-10-01 23:37:35 +01:00
7f151366b1 region comments 2024-09-30 23:29:27 +01:00
12e358df29 working on functionality and layout design 2024-09-30 23:29:16 +01:00
79384ff737 filter modal 2024-09-30 16:15:24 +01:00
98335fce60 render whitelist 2024-09-30 14:09:29 +01:00
6664f2d4f5 abstract load values for select fields 2024-09-30 14:03:42 +01:00
186ba3d21a implement abstract bool column rendering 2024-09-30 14:03:27 +01:00
903f09279a render boolean columns abstract func 2024-09-30 14:03:05 +01:00
876e7ba204 styles functionality & rely more on abstract methods 2024-09-30 13:12:34 +01:00
23d2d44c04 cleaner servertabs look & remove unused css 2024-09-30 13:12:00 +01:00
57e84b1722 margin adjust 2024-09-30 13:11:28 +01:00
0819bd8fcb margin adjust 2024-09-30 13:11:22 +01:00
303a40a840 add filters tab stuff 2024-09-30 13:07:52 +01:00
1a4694421b useless id and name attr removed 2024-09-29 23:46:36 +01:00
dba6460180 remove plus btn on content table 2024-09-29 23:46:16 +01:00
08037d7846 fix regex issue not matching correctly 2024-09-29 23:46:06 +01:00
5d92cd4f7f non-functional generate btn for future use 2024-09-29 23:45:51 +01:00
b7cc682280 abstract modal funcs 2024-09-29 23:45:23 +01:00
2189563b2b max subscriptions limit 2024-09-29 23:44:59 +01:00
8026e0cf84 sub content modal 2024-09-29 19:08:44 +01:00
21cde56f31 include style modal 2024-09-29 19:07:59 +01:00
c364869170 correctly filter content by related subscription 2024-09-29 19:07:43 +01:00
711407bc58 remove unused extra notes 2024-09-29 19:07:27 +01:00
13110044eb create default message style per server 2024-09-29 19:07:06 +01:00
8ef9c9d9dd style form & table layout 2024-09-29 19:06:55 +01:00
67ae103676 enable/disable table controls 2024-09-29 19:06:19 +01:00
019d6be9f5 content view 2024-09-29 19:05:42 +01:00
8133b2c665 remove undefined rule name & add todo 2024-09-28 01:02:13 +01:00
babbcae8d9 tab button icons 2024-09-28 01:01:49 +01:00
72dece5b28 remove borders & make buttons icon only 2024-09-28 00:38:13 +01:00
253f99adc8 custom table search css 2024-09-28 00:37:55 +01:00
aa9d2cdfbe colour styling 2024-09-28 00:37:26 +01:00
b6ff653014 load style options & model fields 2024-09-27 17:43:09 +01:00
1b28600c39 message styles table 2024-09-27 17:42:42 +01:00
db4dcc4175 model migrations 2024-09-27 17:42:19 +01:00
68b5174222 name, colour and publish threshold fields 2024-09-27 17:41:46 +01:00
ce263b23f4 fix error when being rate limited 2024-09-27 12:22:59 +01:00
66e41ab53d modals rewrite 2024-09-27 12:22:10 +01:00
bc3a88e159 toggle sub activeness via switch 2024-09-26 16:47:29 +01:00
20e713d8df bot permissions view (for later)
will use to check whether the bot has permissions in any given server
2024-09-26 16:47:15 +01:00
f4c0f0b0f3 fix broken DRF json renderer 2024-09-26 15:57:02 +01:00
3675bef22a search, refresh and row select 2024-09-26 14:10:18 +01:00
293c02a6fd page sizer and total items count 2024-09-26 12:29:04 +01:00
4c0928e10c pagination trigger data fetch & load 2024-09-26 11:58:44 +01:00
014b30c3ec pagination 2024-09-26 11:34:05 +01:00
8bcd997b4d working on tables functionality 2024-09-26 00:26:20 +01:00
1b3aa12ee9 update to api url changes 2024-09-26 00:26:07 +01:00
5e3653ca4a unnest urls 2024-09-26 00:10:47 +01:00
56db239248 filtersets, ordering and searchable fields 2024-09-26 00:10:34 +01:00
a2acc7298c tables with rewrite 2024-09-25 17:20:33 +01:00
cc3c35be6d table border colour 2024-09-25 15:22:52 +01:00
0530b06df4 fill screen width 2024-09-25 14:00:36 +01:00
4ebb33e732 text colour 2024-09-25 12:53:42 +01:00
b4bef2598f remove test 2024-09-25 12:53:34 +01:00
7ae2555920 manual table 2024-09-25 12:50:06 +01:00
12bff711bc less placeholders 2024-09-25 11:43:23 +01:00
d0c6c7c743 server button placeholders 2024-09-25 11:41:46 +01:00
2d568cb913 use correct domain 2024-09-25 11:15:39 +01:00
6ae4b7f319 rearrange static and template files 2024-09-25 11:06:49 +01:00
57dc57786c refactor for server changes 2024-09-24 22:47:26 +01:00
a70079d635 footer removal 2024-09-24 22:47:17 +01:00
86074d2e13 replace old models with new 2024-09-24 17:28:37 +01:00
8b803f5df0 styles tab - incomplete 2024-09-24 14:14:52 +01:00
edf047f148 rewrite generating and loading servers 2024-09-24 14:07:47 +01:00
9bcb99dd30 class based view 2024-09-23 23:10:48 +01:00
181930d687 sync servers & members with Discord 2024-09-23 22:13:36 +01:00
515a165cdb warning and ownerid 2024-09-20 15:58:06 +01:00
ecfd4a37f3 server setup via api 2024-09-20 00:10:27 +01:00
345f70d30f rewrite models and api 2024-09-19 12:25:18 +01:00
454ba77908 Update servers.js
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-09-18 22:31:35 +01:00
73d72ce21e fix incorrect savedguilds as an admin
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
results for all users are returned when acting as an administrator, this is fine, but client-side filtering needs to be implemented, so these results don't muddy the UI
2024-09-18 22:31:16 +01:00
71b9b2f437 testing out some model rewrites
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-09-18 17:27:52 +01:00
b99ef216eb label change 2024-09-18 17:27:36 +01:00
3a41123868 update changelog
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-09-18 16:08:58 +01:00
286ac392d5 spacing fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 17s
2024-09-18 14:50:06 +01:00
049beb778f yellow dot - mobile friendly
All checks were successful
Build and Push Docker Image / build (push) Successful in 17s
2024-09-18 14:13:00 +01:00
1ae571489d table controls mobile friendly
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-09-18 12:17:16 +01:00
bc04ec0996 mobile friendly top bar for viewing servers
Some checks failed
Build and Push Docker Image / build (push) Failing after 6m53s
2024-09-18 11:44:39 +01:00
8b97f63710 align
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-09-18 07:55:47 +01:00
0b423a7e43 non-functional advanced submodal additions & changes
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-09-17 15:48:31 +01:00
1e471bffcc update changelog 2024-09-17 10:41:35 +01:00
8f0e89b35c alert spacing 2024-09-17 10:41:28 +01:00
55fd075c1d 'invite bot' warning alert is mobile friendly 2024-09-17 10:32:32 +01:00
9cd020468f removed unused & mobile support
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
2024-09-17 00:36:41 +01:00
97cb956db5 button spacing on small screens
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-09-17 00:36:20 +01:00
f9fe43e17d spacing for smaller mobile screens 2024-09-17 00:35:53 +01:00
634cd6fed3 Update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-09-16 17:35:54 +01:00
76637c1d03 tidy up all form switches
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-09-16 17:27:38 +01:00
48290a2df1 Update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-09-16 16:24:16 +01:00
63edbf7c59 indicator for selected server 2024-09-16 16:24:12 +01:00
3287227f62 validation 2024-09-16 13:27:35 +01:00
7f580a0f1f mobile friendly buttons
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-09-16 10:24:03 +01:00
3e85525f53 Update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2024-09-15 15:36:10 +01:00
9fb0906723 unique rules functional on sub modal
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-09-15 15:35:01 +01:00
3acb172432 unique rules migration 2024-09-15 15:34:45 +01:00
0fe1633beb Update CHANGELOG.md 2024-09-15 14:24:13 +01:00
c173664d5a Update changelog to follow 'Keep a Changelog' 2024-09-15 14:23:24 +01:00
221ea5cc79 remove borders 2024-09-14 20:12:31 +01:00
2b6546bd76 server sidebar support smaller screens 2024-09-14 20:12:22 +01:00
b9c0f237f7 unique content rule frontend (incomplete)
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
not finished for saving, editing existing subscriptions.

Only appears as option under 'advanced' at the minute, choices have no affect.
2024-09-14 00:59:08 +01:00
b91d38dc81 unique content rule backend 2024-09-14 00:58:16 +01:00
6942fcc12e next ver is 0.4.0
All checks were successful
Build and Push Docker Image / build (push) Successful in 13s
due to larger ui and api additions and changes (non-breaking)
2024-09-13 23:37:02 +01:00
374ea516bd region comments
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s
2024-09-13 23:35:20 +01:00
0c028417b0 transition effect for server sidebar hover 2024-09-13 23:35:12 +01:00
a4db7f0eac Update CHANGELOG.md
All checks were successful
Build and Push Docker Image / build (push) Successful in 22s
2024-09-13 19:58:19 +01:00
135739f856 wider sidebar support
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s
doesnt support small screen yet
2024-09-13 19:56:54 +01:00
651622095e wider screen 2024-09-13 15:12:11 +01:00
1d5117614e notifications test using notifyjs
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-09-13 15:09:57 +01:00
53ddffc3ba content order and footer links
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
2024-09-13 14:58:52 +01:00
386 changed files with 42497 additions and 4438 deletions

View File

@ -1,73 +1,214 @@
# Changelog
**v0.3.4**
All notable changes to this project will be documented in this file.
- Fix: Refresh data tables after deleting any number of entries (corbz/PYRSS-Website#38)
- Enhancement: `data-field` implementation for subscription form
- Enhancement: Improved the offcanvas navbar on smaller screens, to be less ugly
- Fix: Select2 dropdown search bars always using light theme, regardless of user choice
- Enhancement: Clearer help label on the 'Add Server' modal/form
- Enhancement: Add wiki link button to footer
- Fix: "ordering=unknown" by only applying ordering if it's truthy
- Fix: Exception caused by unfinished queryset method on the `/api/guild-settings/` endpoint
- Enhancement: rewrote `confirmDeleteModal` into less specific `confirmationModal` with specifiable styles
- Fix: Several potential xss attack vectors
- Fix: 'Add Pyrss' button incorrectly not opening in new tab
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
**v0.3.3**
## [Unreleased] [0.4.1] - xxxx-xx-xx
- Enhancement: Added some refreshing new fonts (sora & atkison hyperlegible)
- Enhancement: all table cells no longer wrap text
- Fix: fixed a padding issue with link-style buttons in table cells
- Enhancement: `DISCORD_SCOPES` as env var, rather than hard coded
- Other: added license file
### Added
**v0.3.2**
- 'Invalid' appearance for select-2 controls, upon bad form submission
- Shorthand functions for setting/clearing the "spot" classes on ".sidebar-item"s
- Fix: invite link refered to wrong Discord application, because the client Id was hard coded
- Enhancement: enabled pagination for the `/api/guild-settings/` endpoint
### Fixed
**v0.3.1**
- Storing Discord-provided snowflake ID's in a postgresql database. Now using 'PositiveLargeIntegerField' to support this
- Sidebar server "placeholder" elements running offscreen on desktop - fixed by reducing them by 2
- Theme button missing icon on page load. Created an init function for properly setting up the theme on page load.
- Fix: issues brought from previous version
### Changed
**breaking changes v.0.3.0**
- Keep the hover state of sidebar buttons while their respective dropdown's are showing
- Made the 'channels' field on subscriptions required, at least one channel must be chosen
- Deleting a used message style, will cause subscriptions using it to fall back to their default message styles
- Enhancement: store guild settings in separate model
- Breaking: server `default_embed_colour` setting will be reset due to changes on how settings are stored
- Enhancement: moved server settinsg from tab to pop-out modal
- Enhancement: `active` flag added as server setting, soft-toggles activeness of associated subscriptions
- Fix: Bad text alignment for some table button links
- Fix & Enhancement: some small clean-up/patches here and there
- Fix: Incorrect margin on 'server settings' & 'new server' modal buttons
- Fix: Table "X Results" text now correctly plural only if `results > 1`
## [Unreleased] [0.4.0] - xxxx-xx-xx [BREAKING]
**v0.2.2**
### Added
- Enhancement: added open graph meta tags
- Fix: csrf trusted origins warning, from not including url protocol
- Fix/Enhancement: Replaced `<a href="#" onclick="doThing()">` buttons with `<button>`.
- `UniqueContentRule` model, allows the user to determine how unique RSS items are defined
- `unique_content_rules` attribute to the `Subscription` model, many-to-many relationship with the related model
- Web interface method of setting a Subscription's unique content rules, through a multi-select field
- Migrations to allow older versions to seemlessly upgrade for this change, by creating the `UniqueContentRule` instances and setting default content rules on Subscriptions
- Column on subscription table for the new unique content rules
- Validation indicators and messages to the sub modal fields
- Help text to various form fields that lacked it
**v0.2.1**
### Fixed
- Enhancement: Added confirmation modal for closing a server
- Footer links pointing to older domain
- Tracked content ListView API incorrectly ordering by oldest first, instead of newest first
**v0.2.0**
### Changed
- Enhancement: Improved warning when server doesn't include bot member
- Enhancement: Made it easier to update labels to the current server's details
- Enhancement: 'Go Back' button on the server page, takes the user to the 'select a server' page.
- Docs: Further documented `static/home/servers.js`
- Other: removed some unused/unreferenced code
- General rewrite of entire web interface
- General rewrite of entire backend
- Web interface now uses the full device width, rather than a smaller maximum width
- Server sidebar use more width, also displays name and guild ID, becomes smaller on small devices
- Update changelog to follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- Made the tables and their respective buttons more mobile friendly
- Tidy up the layout of switches on some forms
- Help text for the switch fields on the Filters form, to better indicate their function
- 'Invite PYRSS' yellow warning alert made to be mobile friendly
- Table controls (pagination & page resizer) adjusted to support mobile devices
- Moved footer items to bottom of sidebar
- Changed style of navbar buttons
**v0.1.14**
### Removed
- Fix: layout issue for the 'select a server' page was off-centre
- Unused code for the legacy 'server settings tab' from before it became a modal
**v0.1.13**
## [0.3.4] - 2024-09-12
- Docs: Start of changelog
- Fix: remove db flush from entrypoint file
### Added
**v0.1.0**
- Add wiki link button to footer
- Initial Release
### Fixed
- Refresh data tables after deleting any number of entries (corbz/PYRSS-Website#38)
- Select2 dropdown search bars always using light theme, regardless of user choice
- Fixed "ordering=unknown" in api calls by only applying ordering if it's truthy
- Exception caused by unfinished queryset method on the `/api/guild-settings/` endpoint
- Several potential xss attack vectors
- 'Add Pyrss' button incorrectly not opening in new tab
### Changed
- `data-field` implementation for subscription form
- Improved the offcanvas navbar on smaller screens, to be less ugly
- Clearer help label on the 'Add Server' modal/form
- rewrote `confirmDeleteModal` into less specific `confirmationModal` with specifiable styles
## [0.3.3] - 2024-08-24
### Added
- Added font: sora
- Added font: Atkinson Hyperlegible
- added license file
### Fixed
- Fixed a padding issue with link-style buttons in table cells
### Changed
- All table cells no longer wrap text
- `DISCORD_SCOPES` as env var, rather than hard coded
## [0.3.2] - 2024-08-18
### Fixed
- Invite link refered to wrong Discord application, because the client Id was hard coded
### Changed
- Enabled pagination for the `/api/guild-settings/` endpoint
## [0.3.1] - 2024-08-16
### Fixed
- Colour embeds with multiple hashtag characters, bug from [0.3.0]
## [0.3.0] - 2024-08-16 [BREAKING]
### Added
- `active` flag added as server setting, soft-toggles activeness of associated subscriptions
### Fixed
- Bad text alignment for some table button links
- Incorrect margin on 'server settings' & 'new server' modal buttons
- Table "X Results" text now correctly plural only if `results > 1`
### Changed
- store guild settings in separate model
- moved server settinsg from tab to pop-out modal
- server `default_embed_colour` setting will be reset due to changes on how settings are stored
## [0.2.2] - 2024-08-14
### Added
- Added open graph meta tags
### Fixed
- csrf trusted origins warning, from not including url protocol
### Changed
- Replaced `<a href="#" onclick="doThing()">` buttons with `<button>`.
## [0.2.1] - 2024-08-13
### Added
- Added confirmation modal for closing a server
## [0.2.0] - 2024-08-13
### Added
- 'Go Back' button on the server page, takes the user to the 'select a server' page.
- Documented `static/home/servers.js` with comments
### Changed
- Improved warning when server doesn't include bot member
- Made it easier to update labels to the current server's details
### Removed
- Removed some unused/unreferenced code
## [0.1.15] - 2024-08-13
## [0.1.14] - 2024-08-13
### Fixed
- layout issue for the 'select a server' page was off-centre
## [0.1.13] - 2024-08-12
### Added
- Start of changelog
### Removed
- remove db flush from entrypoint file
## [0.1.12] - 2024-08-12
## [0.1.11] - 2024-08-12
## [0.1.10] - 2024-08-12
## [0.1.9] - 2024-08-12
## [0.1.8] - 2024-08-12
## [0.1.7] - 2024-08-12
## [0.1.6] - 2024-08-11
## [0.1.5] - 2024-08-11
## [0.1.4] - 2024-08-09
## [0.1.3] - 2024-08-08
## [0.1.2] - 2024-08-05
## [0.1.1] - 2024-08-04
## [0.1.0] - 2024-08-04

3
apps/api/errors.py Normal file
View File

@ -0,0 +1,3 @@
class NotAMemberError(Exception):
pass

View File

@ -2,22 +2,19 @@
from rest_framework.permissions import BasePermission
from apps.home.models import Server
from apps.authentication.models import ServerMember
class UserHasDiscordPermissions(BasePermission):
class HasServerAccess(BasePermission):
"""
Permission to ensure that the user is permitted to make
changes on behalf of the server they are representing.
An object permission class, the object must have a 'server' attribute.
"""
message = "You lack administrator access to this server"
def has_object_permission(self, request, view, obj):
if not hasattr(obj, "server"):
raise Exception(f"obj '{obj}' must have attr 'server'")
# class SubscriptionServerMember(BasePermission):
# """
# Permission for each subscription that omits the sub if
# the request user isn't a member of it's server.
# """
# def has_object_permission(self, request, view, obj):
# return obj.server in request.user.servers
return ServerMember.objects.filter(user=request.user, server=obj.server).exists()

27
apps/api/renderers.py Normal file
View File

@ -0,0 +1,27 @@
from rest_framework.renderers import JSONRenderer
# custom renderer to fix a horrible python json issue, that corrupts large integers
# issue example: parsing an integer 1204426362794811453 will corrupt the integer into 1204426362794811400.
class FixedJSONRenderer(JSONRenderer):
def handle_large_ints(self, data):
if isinstance(data, dict):
return {
key: self.handle_large_ints(value) # Recursively apply to nested dicts
if isinstance(value, (dict, list)) else str(value)
if isinstance(value, int) and value > 1000000000000000 else value
for key, value in data.items()
}
elif isinstance(data, list):
return [
self.handle_large_ints(item) # Recursively apply to lists
if isinstance(item, (dict, list)) else str(item)
if isinstance(item, int) and item > 1000000000000000 else item
for item in data
]
return data
def render(self, data, *args, **kwargs):
data = self.handle_large_ints(data) # Preprocess data to convert large ints
return super().render(data, *args, **kwargs)

View File

@ -1,15 +1,26 @@
# -*- encoding: utf-8 -*-
import logging
from urllib.parse import unquote
from django.conf import settings
from rest_framework import serializers
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
from apps.home.models import (
Server,
ContentFilter,
MessageMutator,
MessageStyle,
UniqueContentRule,
DiscordChannel,
Subscription,
Content,
)
log = logging.getLogger(__name__)
# region Dynamic Model
# This DynamicModelSerializer is from a StackOverflow user in an obscure thread.
# I wish that I could remember which thread, because god bless that man.
@ -109,108 +120,224 @@ class DynamicModelSerializer(serializers.ModelSerializer):
abstract = True
class SubChannelSerializer(DynamicModelSerializer):
"""
Serializer for SubChannel Model.
"""
# region Servers
class ServerSerializer(DynamicModelSerializer):
id = serializers.CharField()
class Meta:
model = SubChannel
fields = ("id", "channel_id", "channel_name", "subscription")
model = Server
fields = (
"id",
"name",
"icon_hash",
"is_bot_operational",
"active"
)
class FilterSerializer(DynamicModelSerializer):
"""
Serializer for the Filter Model.
"""
# region Filters
class ContentFilterSerializer(DynamicModelSerializer):
class Meta:
model = Filter
fields = ("id", "name", "matching_algorithm", "match", "is_insensitive", "is_whitelist", "guild_id")
model = ContentFilter
fields = (
"id",
"server",
"name",
"match",
"matching_algorithm",
"is_insensitive",
"is_whitelist"
)
class ArticleMutatorSerializer(DynamicModelSerializer):
# region Msg Mutators
class MessageMutatorSerializer(DynamicModelSerializer):
class Meta:
model = ArticleMutator
model = MessageMutator
fields = ("id", "name", "value")
class SubscriptionSerializer_GET(DynamicModelSerializer):
"""
Serializer for the Subscription Model.
"""
# region Msg Styles
article_title_mutators = ArticleMutatorSerializer(many=True)
article_desc_mutators = ArticleMutatorSerializer(many=True)
active = serializers.BooleanField(initial=True)
class MessageStyleSerializer(DynamicModelSerializer):
title_mutator_detail = serializers.SerializerMethodField()
description_mutator_detail = serializers.SerializerMethodField()
class Meta:
model = MessageStyle
fields = (
"id",
"server",
"name",
"is_embed",
"colour",
"is_hyperlinked",
"show_author",
"show_timestamp",
"show_images",
"fetch_images",
"title_mutator",
"title_mutator_detail",
"description_mutator",
"description_mutator_detail",
"auto_created"
)
read_only_fields = ("auto_created",)
def get_title_mutator_detail(self, obj: MessageStyle):
request = self.context.get("request")
if request and request.method == "GET":
return MessageMutatorSerializer(obj.title_mutator).data
return {}
def get_description_mutator_detail(self, obj: MessageStyle):
request = self.context.get("request")
if request and request.method == "GET":
return MessageMutatorSerializer(obj.description_mutator).data
return {}
# region Rules
class UniqueContentRuleSerializer(DynamicModelSerializer):
class Meta:
model = UniqueContentRule
fields = ("id", "name", "value")
# region Subscriptions
class DiscordChannelField(serializers.PrimaryKeyRelatedField):
def to_internal_value(self, data):
try:
data = int(data)
except (TypeError, ValueError):
self.fail("invalid", pk_value=data)
return super().to_internal_value(data)
def to_representation(self, value):
return str(value.pk)
class NestedDiscordChannelSerializer(DynamicModelSerializer):
class Meta:
model = DiscordChannel
fields = ("id", "name", "is_nsfw")
class SubscriptionSerializer(DynamicModelSerializer):
filters = serializers.PrimaryKeyRelatedField(
queryset=ContentFilter.objects.all(),
many=True
)
channels = DiscordChannelField(
queryset=DiscordChannel.objects.all(),
many=True,
required=True,
allow_empty=False
)
channels_detail = serializers.SerializerMethodField()
filters_detail = serializers.SerializerMethodField()
message_style = serializers.PrimaryKeyRelatedField(
queryset=MessageStyle.objects.all(),
required=True,
allow_null=False
)
message_style_detail = serializers.SerializerMethodField()
unique_rules_detail = serializers.SerializerMethodField()
class Meta:
model = Subscription
fields = (
"id", "name", "url", "guild_id", "channels_count", "creation_datetime", "extra_notes", "filters",
"article_title_mutators", "article_desc_mutators", "article_fetch_image", "published_threshold", "embed_colour", "active"
"id",
"server",
"name",
"url",
"created_at",
"updated_at",
"extra_notes",
"active",
"publish_threshold",
"channels",
"channels_detail",
"filters",
"filters_detail",
"message_style",
"message_style_detail",
"unique_rules",
"unique_rules_detail"
)
def get_channels_detail(self, obj: Subscription):
request = self.context.get("request")
if request.method == "GET":
return NestedDiscordChannelSerializer(obj.channels.all(), many=True).data
return []
class SubscriptionSerializer_POST(DynamicModelSerializer):
"""
Serializer for the Subscription Model.
"""
def get_filters_detail(self, obj: Subscription):
request = self.context.get("request")
if request.method == "GET":
return ContentFilterSerializer(obj.filters.all(), many=True).data
return []
def get_message_style_detail(self, obj: Subscription):
request = self.context.get("request")
if request.method == "GET":
return MessageStyleSerializer(obj.message_style).data
return {}
def get_unique_rules_detail(self, obj: Subscription):
request = self.context.get("request")
if request.method == "GET":
return UniqueContentRuleSerializer(obj.unique_rules.all(), many=True).data
return []
def validate(self, data):
server = data.get("server") or self.context.get("server")
if not server:
return data
# Enforce max subscriptions per server
subscriptions_count = Subscription.objects.filter(server=server).count();
if subscriptions_count >= settings.MAX_SUBSCRIPTIONS_PER_SERVER:
raise serializers.ValidationError(
f"Cannot create more than {settings.MAX_SUBSCRIPTIONS_PER_SERVER} subscriptions for this server."
)
# Prevent using filters from a different server
selected_filters = data.get("filters", [])
valid_filter_ids = ContentFilter.objects.filter(server=server).values_list("id", flat=True)
if any(fltr.id not in valid_filter_ids for fltr in selected_filters):
raise serializers.ValidationError(
{"filters": "All filters must belong to the specified server."}
)
# Prevent using message styles from a different server
message_style = data.get("message_style")
if message_style and message_style.server != server:
raise serializers.ValidationError(
{"message_style": "Message style must belong to the specified server."}
)
return data
# region Content
class ContentSerializer(DynamicModelSerializer):
class Meta:
model = Subscription
model = Content
fields = (
"id", "name", "url", "guild_id", "channels_count", "creation_datetime", "extra_notes", "filters",
"article_title_mutators", "article_desc_mutators", "article_fetch_image", "published_threshold", "embed_colour", "active"
"id",
"subscription",
"item_id",
"item_guid",
"item_url",
"item_title",
"item_content_hash"
)
class SavedGuildSerializer(DynamicModelSerializer):
"""
Serializer for the SavedGuild model.
"""
class Meta:
model = SavedGuilds
fields = ("id", "guild_id", "name", "icon", "added_by", "permissions", "owner")
class GuildSettingsSerializer(DynamicModelSerializer):
"""
Serializer for the GuildSettings model.
"""
class Meta:
model = GuildSettings
fields = ("id", "guild_id", "default_embed_colour", "active")
class TrackedContentSerializer_GET(DynamicModelSerializer):
"""
Serializer for the TrackedContent model.
"""
subscription = SubscriptionSerializer_GET()
class Meta:
model = TrackedContent
fields = ("id", "guid", "title", "url", "subscription", "channel_id", "message_id", "blocked", "creation_datetime")
# def to_representation(self, instance):
# representation = super().to_representation(instance)
# log.info(representation.get("guid", "nothing"))
# if 'guid' in representation:
# representation['guid'] = unquote(representation['guid'])
# log.info(representation.get("guid", "nothing"))
# return representation
class TrackedContentSerializer_POST(DynamicModelSerializer):
"""
Serializer for the TrackedContent model.
"""
class Meta:
model = TrackedContent
fields = ("id", "guid", "title", "url", "subscription", "channel_id", "message_id", "blocked", "creation_datetime")

View File

@ -4,62 +4,65 @@ from django.urls import path, include
from rest_framework.authtoken.views import obtain_auth_token
from .views import (
SubChannel_ListView,
SubChannel_DetailView,
Filter_ListView,
Filter_DetailView,
Server_ListView,
Server_DetailView,
ContentFilter_ListView,
ContentFilter_DetailView,
MessageMutator_ListView,
MessageMutator_DetailView,
MessageStyle_ListView,
MessageStyle_DetailView,
Subscription_ListView,
Subscription_DetailView,
Subscription_SubChannelView,
SavedGuild_ListView,
SavedGuild_DetailView,
TrackedContent_ListView,
TrackedContent_DetailView,
ArticleMutator_ListView,
ArticleMutator_DetailView,
GuildSettings_ListView,
GuildSettings_DetailView
Content_ListView,
Content_DetailView,
UniqueContentRule_ListView,
UniqueContentRule_DetailView
)
urlpatterns = [
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
path("api-token-auth/", obtain_auth_token),
path("subchannel/", include([
path("", SubChannel_ListView.as_view(), name="subchannel"),
path("<str:pk>/", SubChannel_DetailView.as_view(), name="subchannel-detail")
# region Servers
path("servers/", include([
path("", Server_ListView.as_view()),
path("<int:pk>/", Server_DetailView.as_view())
])),
path("filter/", include([
path("", Filter_ListView.as_view(), name="filter"),
path("<str:pk>/", Filter_DetailView.as_view(), name="filter-detail")
# region Filters
path("filters/", include([
path("", ContentFilter_ListView.as_view()),
path("<int:pk>/", ContentFilter_DetailView.as_view())
])),
path("subscription/", include([
path("", Subscription_ListView.as_view(), name="subscription"),
path("<str:pk>/", include([
path("", Subscription_DetailView.as_view(), name="subscription-detail"),
path("subchannels/", Subscription_SubChannelView.as_view(), name="subscription-channels")
]))
# region Message Mutators
path("message-mutators/", include([
path("", MessageMutator_ListView.as_view()),
path("<int:pk>/", MessageMutator_DetailView.as_view())
])),
path("saved-guilds/", include([
path("", SavedGuild_ListView.as_view(), name="saved-guilds"),
path("<int:pk>/", SavedGuild_DetailView.as_view(), name="saved-guilds-detail")
# region Message Styles
path("message-styles/", include([
path("", MessageStyle_ListView.as_view()),
path("<int:pk>/", MessageStyle_DetailView.as_view())
])),
path("guild-settings/", include([
path("", GuildSettings_ListView.as_view(), name="guild-settings"),
path("<int:pk>/", GuildSettings_DetailView.as_view(), name="guild-settings-detail")
# region Subscriptions
path("subscriptions/", include([
path("", Subscription_ListView.as_view()),
path("<int:pk>/", Subscription_DetailView.as_view())
])),
path("tracked-content/", include([
path("", TrackedContent_ListView.as_view(), name="tracked-content"),
path("<path:pk>/", TrackedContent_DetailView.as_view(), name="tracked-content-detail")
# region Content
path("content/", include([
path("", Content_ListView.as_view()),
path("<int:pk>/", Content_DetailView.as_view())
])),
path("article-mutator/", include([
path("", ArticleMutator_ListView.as_view(), name="article-mutator"),
path("<int:pk>/", ArticleMutator_DetailView.as_view(), name="article-mutator-detail")
])),
# region Unique Rules
path("unique-content-rules/", include([
path("", UniqueContentRule_ListView.as_view()),
path("<int:pk>/", UniqueContentRule_DetailView.as_view())
]))
]

View File

@ -2,29 +2,34 @@
import logging
from django.db.models import Subquery
from django.db.utils import IntegrityError
from django_filters import rest_framework as rest_filters
from rest_framework import status, permissions, filters, generics
from rest_framework.response import Response
from rest_framework import permissions, filters, generics
from rest_framework.pagination import PageNumberPagination
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
from rest_framework.parsers import MultiPartParser, FormParser
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
from apps.authentication.models import DiscordUser
from apps.home.models import (
Server,
ContentFilter,
MessageMutator,
MessageStyle,
Subscription,
Content,
UniqueContentRule
)
from apps.authentication.models import DiscordUser, ServerMember
from .metadata import ExpandedMetadata
from .serializers import (
SubChannelSerializer,
FilterSerializer,
SubscriptionSerializer_GET,
SubscriptionSerializer_POST,
SavedGuildSerializer,
TrackedContentSerializer_GET,
TrackedContentSerializer_POST,
ArticleMutatorSerializer,
GuildSettingsSerializer
ServerSerializer,
ContentFilterSerializer,
MessageMutatorSerializer,
MessageStyleSerializer,
SubscriptionSerializer,
ContentSerializer,
UniqueContentRuleSerializer
)
from .permissions import HasServerAccess
from .errors import NotAMemberError
log = logging.getLogger(__name__)
@ -41,580 +46,178 @@ def is_automated_admin(user):
return user.user_type == DiscordUser.USER_TYPES.AUTOMATED_USER and user.is_superuser
# =================================================================================================
# SubChannel Views
class SubChannel_ListView(generics.ListCreateAPIView):
"""
View to provide a list of SubChannel model instances.
Can also be used to create a new instance.
Supports: GET, POST
"""
class ListView(generics.ListAPIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
pagination_class = DefaultPagination
serializer_class = SubChannelSerializer
queryset = SubChannel.objects.all().order_by("id")
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["id", "channel_id", "channel_name", "subscription"]
search_fields = ["channel_name"]
def get_queryset(self):
if self.request.user.is_superuser:
return SubChannel.objects.all()
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
guild_ids = [guild.guild_id for guild in saved_guilds]
return SubChannel.objects.filter(subscription__guild_id__in=guild_ids)
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
except IntegrityError as err:
return Response(
{"detail": str(err)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
exception=True
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class SubChannel_DetailView(generics.RetrieveUpdateDestroyAPIView):
"""
View to provide details on a particular SubChannel model instances.
Supports: GET, PUT, PATCH, DELETE
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
serializer_class = SubChannelSerializer
queryset = SubChannel.objects.all().order_by("id")
def get_queryset(self):
if self.request.user.is_superuser:
return SubChannel.objects.all()
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
guild_ids = [guild.guild_id for guild in saved_guilds]
return SubChannel.objects.filter(subscription__guild_id__in=guild_ids)
# =================================================================================================
# Filter Views
class Filter_ListView(generics.ListCreateAPIView):
"""
View to provide a list of Filter model instances.
Can also be used to create a new instance.
Supports: GET, POST
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
pagination_class = DefaultPagination
serializer_class = FilterSerializer
metadata_class = ExpandedMetadata
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["id", "name", "matching_algorithm", "match", "is_insensitive", "is_whitelist", "guild_id"]
search_fields = ["name", "match"]
def get_queryset(self):
if self.request.user.is_superuser:
return Filter.objects.all().order_by("id")
saved_guild_ids = SavedGuilds.objects \
.filter(added_by=self.request.user.id) \
.values("guild_id")
return Filter.objects \
.filter(guild_id__in=Subquery(saved_guild_ids)) \
.order_by("id")
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
except IntegrityError as err:
return Response(
{"detail": str(err)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
exception=True
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class Filter_DetailView(generics.RetrieveUpdateDestroyAPIView):
"""
View to provide details on a particular Filter model instances.
Supports: GET, PUT, PATCH, DELETE
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
serializer_class = FilterSerializer
def get_queryset(self):
if self.request.user.is_superuser:
return Filter.objects.all().order_by("id")
saved_guild_ids = SavedGuilds.objects \
.filter(added_by=self.request.user.id) \
.values("guild_id")
return Filter.objects \
.filter(guild_id__in=Subquery(saved_guild_ids)) \
.order_by("id")
# =================================================================================================
# Subscription Views
class Subscription_ListView(generics.ListCreateAPIView):
"""
View to provide a list of Subscription model instances.
Can also be used to create a new instance.
Supports: GET, POST
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
pagination_class = DefaultPagination
metadata_class = ExpandedMetadata
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = [
"id", "name", "url", "guild_id", "creation_datetime", "extra_notes", "filters",
"article_title_mutators", "article_desc_mutators", "embed_colour", "published_threshold", "active"
]
search_fields = ["name", "url", "extra_notes"]
ordering_fields = ["name", "creation_datetime", "active"]
read_serializer_class = SubscriptionSerializer_GET
write_serializer_class = SubscriptionSerializer_POST
def get_serializer_class(self):
if self.request.method == "POST":
return self.write_serializer_class
return self.read_serializer_class
def get_queryset(self):
if self.request.user.is_superuser:
return Subscription.objects.all().order_by("-creation_datetime")
saved_guild_ids = SavedGuilds.objects \
.filter(added_by=self.request.user.id) \
.values("guild_id")
return Subscription.objects \
.filter(guild_id__in=Subquery(saved_guild_ids)) \
.order_by("-creation_datetime")
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
except IntegrityError as err:
return Response(
{"detail": str(err)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
exception=True
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class Subscription_DetailView(generics.RetrieveUpdateDestroyAPIView):
"""
View to provide details on a particular Subscription model instances.
Supports: GET, PUT, PATCH, DELETE
"""
class ListCreateView(generics.ListCreateAPIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
serializer_class = SubscriptionSerializer_POST
def get_queryset(self):
if self.request.user.is_superuser:
return Subscription.objects.all().order_by("-creation_datetime")
saved_guild_ids = SavedGuilds.objects \
.filter(added_by=self.request.user.id) \
.values("guild_id")
return Subscription.objects \
.filter(guild_id__in=Subquery(saved_guild_ids)) \
.order_by("-creation_datetime")
class Subscription_SubChannelView(generics.DestroyAPIView):
"""
View to erase all subscription channels quickly.
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def delete(self, *args, **kwargs):
if self.request.user.is_superuser:
subscriptions = Subscription.objects.all()
else:
saved_guild_ids = SavedGuilds.objects \
.filter(added_by=self.request.user.id) \
.values("guild_id")
subscriptions = Subscription.objects \
.filter(guild_id__in=Subquery(saved_guild_ids))
if not subscriptions:
return Response(
{"detail": "You are forbidden from viewing this subscription"},
status=status.HTTP_403_FORBIDDEN
)
subscription = subscriptions.filter(id=kwargs["pk"]).first()
if not subscription:
return Response(
{"detail": "not found"},
status=status.HTTP_404_NOT_FOUND
)
channels = SubChannel.objects.filter(subscription=subscription)
channels.delete();
return Response(status=status.HTTP_204_NO_CONTENT)
# =================================================================================================
# SavedGuild Views
class SavedGuild_ListView(generics.ListCreateAPIView):
"""
View to provide a list of SavedGuild model instances.
Can also be used to create a new instance.
Supports: GET, POST
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
pagination_class = None
serializer_class = SavedGuildSerializer
metadata_class = ExpandedMetadata
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["id", "guild_id", "name", "icon", "added_by", "permissions", "owner"]
search_fields = ["name"]
def get_queryset(self):
if self.request.user.is_superuser:
return SavedGuilds.objects.all()
return SavedGuilds.objects.filter(added_by=self.request.user)
def post(self, request):
# TODO:
# the data used for admin/owner verification is provided
# from the client, this is a potential attack vector, and
# should be rewritten.
is_owner = request.data["owner"].lower() == "true"
# Check user is admin in server
if not (self.is_server_admin(request.data["permissions"]) or is_owner):
return Response(
{"detail": "You must be a server administrator"},
status=status.HTTP_403_FORBIDDEN,
exception=False
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
except IntegrityError as err:
return Response(
{"detail": str(err)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
exception=True
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def is_server_admin(self, permissions) -> bool:
return (int(permissions) & 1 << 3) == 1 << 3
class SavedGuild_DetailView(generics.RetrieveDestroyAPIView):
"""
View to provide details on a particular SavedGuild model instances.
Supports: GET, DELETE
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
serializer_class = SavedGuildSerializer
def get_queryset(self):
if self.request.user.is_superuser:
return SavedGuilds.objects.all()
return SavedGuilds.objects.filter(added_by=self.request.user)
# =================================================================================================
# GuildSettings Views
class GuildSettings_ListView(generics.ListCreateAPIView):
"""
View to provide a list of GuildSettings model instances.
Can also be used to create a new instance.
Supports: GET, POST
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
pagination_class = DefaultPagination
serializer_class = GuildSettingsSerializer
metadata_class = ExpandedMetadata
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["id", "guild_id", "default_embed_colour", "active"]
def get_queryset(self):
if self.request.user.is_superuser:
return GuildSettings.objects.all()
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
guild_ids = [guild.guild_id for guild in saved_guilds]
return GuildSettings.objects.filter(guild_id__in=guild_ids)
def post(self, request):
saved_guilds = SavedGuilds.objects.filter(added_by=request.user)
if not saved_guilds:
return Response(
{"detail": "You must have an instance of saved guild with this guild_id"},
status=status.HTTP_403_FORBIDDEN,
exception=False
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
except IntegrityError as err:
return Response(
{"detail": str(err)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
exception=True
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def is_server_admin(self, permissions) -> bool:
return (int(permissions) & 1 << 3) == 1 << 3
class GuildSettings_DetailView(generics.RetrieveUpdateDestroyAPIView):
"""
View to provide details on a particular GuildSettings model instances.
Supports: GET, DELETE
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
serializer_class = GuildSettingsSerializer
def get_queryset(self):
if self.request.user.is_superuser:
return GuildSettings.objects.all()
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
guild_ids = [guild.guild_id for guild in saved_guilds]
return GuildSettings.objects.filter(guild_id__in=guild_ids)
# =================================================================================================
# TrackedContent Views
class TrackedContent_ListView(generics.ListCreateAPIView):
"""
View to provide a list of TrackedContent model instances.
Can also be used to create a new instance.
Supports: GET, POST
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
pagination_class = DefaultPagination
metadata_class = ExpandedMetadata
queryset = TrackedContent.objects.all().order_by("-creation_datetime")
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["guid", "title", "url", "subscription", "subscription__guild_id", "channel_id", "blocked", "creation_datetime"]
search_fields = ["guid", "title", "url", "subscription__name", "subscription__url"]
ordering_fields = ["title", "subscription", "creation_datetime", "blocked"]
read_serializer_class = TrackedContentSerializer_GET
write_serializer_class = TrackedContentSerializer_POST
def get_serializer_class(self):
if self.request.method == "POST":
return self.write_serializer_class
return self.read_serializer_class
def get_queryset(self):
if self.request.user.is_superuser:
return TrackedContent.objects.all()
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
guild_ids = [guild.guild_id for guild in saved_guilds]
return TrackedContent.objects.filter(subscription__guild_id__in=guild_ids)
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
except IntegrityError as err:
return Response(
{"detail": str(err)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
exception=True
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class TrackedContent_DetailView(generics.RetrieveUpdateDestroyAPIView):
"""
View to provide details on a particular TrackedContent model instances.
Supports: GET, PUT, PATCH, DELETE
"""
class DetailView(generics.RetrieveAPIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
serializer_class = TrackedContentSerializer_POST
queryset = TrackedContent.objects.all().order_by("-creation_datetime")
def get_queryset(self):
if self.request.user.is_superuser:
return TrackedContent.objects.all()
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
guild_ids = [guild.guild_id for guild in saved_guilds]
return TrackedContent.objects.filter(subscription__guild_id__in=guild_ids)
class ArticleMutator_ListView(generics.ListCreateAPIView):
"""
View to provide a list of ArticleMutator model instances.
Can also be used to create a new instance.
Supports: GET, POST
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
metadata_class = ExpandedMetadata
queryset = ArticleMutator.objects.all().order_by("id")
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["id", "name", "value"]
serializer_class = ArticleMutatorSerializer
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
except IntegrityError as err:
return Response(
{"detail": str(err)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
exception=True
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class ArticleMutator_DetailView(generics.RetrieveUpdateDestroyAPIView):
"""
View to provide details on a particular ArticleMutator model instances.
Supports: GET, PUT, PATCH, DELETE
"""
class ChangableDetailView(generics.RetrieveUpdateDestroyAPIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
serializer_class = ArticleMutatorSerializer
queryset = ArticleMutator.objects.all().order_by("id")
class DeletableDetailView(generics.RetrieveDestroyAPIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
# region Servers
class Server_ListView(ListView):
filterset_fields = ("id", "name", "icon_hash", "active")
search_fields = ("name")
ordering_fields = ("id", "name", "active")
serializer_class = ServerSerializer
def get_queryset(self):
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return Server.objects.filter(id__in=servers).order_by("id")
class Server_DetailView(DetailView):
serializer_class = ServerSerializer
def get_queryset(self):
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return Server.objects.filter(id__in=servers)
# region Filters
class ContentFilter_ListView(ListCreateView):
filterset_fields = ("id", "server", "name", "match", "matching_algorithm", "is_insensitive", "is_whitelist")
search_fields = ("name", "match")
ordering_fields = ("id", "server", "name", "match", "matching_algorithm", "is_insensitive", "is_whitelist")
serializer_class = ContentFilterSerializer
def get_queryset(self):
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return ContentFilter.objects.filter(server__in=servers).order_by("name")
class ContentFilter_DetailView(ChangableDetailView):
serializer_class = ContentFilterSerializer
def get_queryset(self):
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return ContentFilter.objects.filter(server__in=servers)
# region Mutators
class MessageMutator_ListView(ListView): # instances of this one are pre-defined ONLY
filterset_fields = ("id", "name", "value")
search_fields = ("name", "value")
ordering_fields = ("id", "name", "value")
serializer_class = MessageMutatorSerializer
def get_queryset(self):
return MessageMutator.objects.all()
class MessageMutator_DetailView(DetailView):
serializer_class = MessageMutatorSerializer
def get_queryset(self):
return MessageMutator.objects.all()
# Message Styles
class MessageStyle_ListView(ListCreateView):
filterset_fields = ("id", "server", "name", "is_embed", "is_hyperlinked", "show_author", "show_timestamp", "show_images", "fetch_images", "title_mutator", "description_mutator")
search_fields = ("name",)
ordering_fields = ("id", "server", "name", "is_embed", "is_hyperlinked", "show_author", "show_timestamp", "show_images", "fetch_images")
serializer_class = MessageStyleSerializer
def get_queryset(self):
return MessageStyle.objects.all()
class MessageStyle_DetailView(ChangableDetailView):
serializer_class = MessageStyleSerializer
def get_queryset(self):
return MessageStyle.objects.all()
# region Subscriptions
class Subscription_ListView(ListCreateView):
filterset_fields = ("id", "server", "name", "url", "created_at", "updated_at", "extra_notes", "active", "publish_threshold", "filters", "message_style", "unique_rules")
search_fields = ("name", "url", "extra_notes")
ordering_fields = ("id", "server", "name", "url", "created_at", "updated_at", "extra_notes", "active", "message_style")
serializer_class = SubscriptionSerializer
def get_queryset(self):
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return Subscription.objects.filter(server__in=servers)
class Subscription_DetailView(ChangableDetailView):
serializer_class = SubscriptionSerializer
def get_queryset(self):
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return Subscription.objects.filter(server__in=servers)
# region Content
class Content_ListView(ListCreateView):
filterset_fields = ("id", "subscription", "subscription__server", "item_id", "item_guid", "item_url", "item_title", "item_content_hash")
search_fields = ("item_id", "item_guid", "item_url", "item_title", "item_content_hash")
ordering_fields = ("id", "subscription", "item_id", "item_guid", "item_url", "item_title", "item_content_hash")
serializer_class = ContentSerializer
def get_queryset(self):
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
subscriptions = Subscription.objects.filter(server__in=servers).values_list("id", flat=True)
return Content.objects.filter(subscription__in=subscriptions).order_by("-subscription__created_at", "id")
class Content_DetailView(ChangableDetailView):
serializer_class = ContentSerializer
def get_queryset(self):
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
subscriptions = Subscription.objects.filter(server__in=servers).values_list("id", flat=True)
return Content.objects.filter(subscription__in=subscriptions).order_by("-subscription__created_at", "id")
# region Unique Rules
class UniqueContentRule_ListView(ListCreateView):
filterset_fields = ("id", "name", "value")
search_fields = ("name", "value")
ordering_fields = ("id", "name", "value")
serializer_class = UniqueContentRuleSerializer
def get_queryset(self):
return UniqueContentRule.objects.all()
class UniqueContentRule_DetailView(ChangableDetailView):
serializer_class = UniqueContentRuleSerializer
def get_queryset(self):
return UniqueContentRule.objects.all()

View File

@ -2,7 +2,7 @@
from django.contrib import admin
from .models import DiscordUser
from .models import DiscordUser, ServerMember
@admin.register(DiscordUser)
@ -10,3 +10,8 @@ class DiscordUserAdmin(admin.ModelAdmin):
list_display = ["id", "username", "global_name", "last_login", "is_staff", "is_superuser", "is_staff"]
list_filter = ["is_staff", "is_superuser", "is_active"]
@admin.register(ServerMember)
class ServerMemberAdmin(admin.ModelAdmin):
pass

View File

@ -1,6 +1,8 @@
# Generated by Django 5.0.4 on 2024-05-03 13:14
# Generated by Django 5.0.4 on 2024-09-24 14:05
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
@ -10,6 +12,7 @@ class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('home', '0001_initial'),
]
operations = [
@ -26,6 +29,8 @@ class Migration(migrations.Migration):
('mfa_enabled', models.BooleanField(help_text='whether the user has two factor enabled on their account', verbose_name='mfa enabled')),
('last_login', models.DateTimeField(default=django.utils.timezone.now, help_text='datetime of the previous login', verbose_name='last login')),
('access_token', models.CharField(help_text='token for the application to make api calls on behalf of the user.', max_length=100, verbose_name='access token')),
('token_expires', models.DateTimeField(help_text='when to request a new access token.', verbose_name='token expires')),
('refresh_token', models.CharField(help_text='token for the application to request a new access token.', max_length=100, verbose_name='refresh token')),
('user_type', models.CharField(choices=[('D', 'discord'), ('A', 'automated')], default='D', help_text='What type of user is this?', max_length=32, verbose_name='user type')),
('is_active', models.BooleanField(default=True, help_text='Use as a "soft delete" rather than deleting the user.', verbose_name='active status')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
@ -37,4 +42,14 @@ class Migration(migrations.Migration):
'abstract': False,
},
),
migrations.CreateModel(
name='ServerMember',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('permissions', models.CharField(max_length=32)),
('is_owner', models.BooleanField(default=False)),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.0.4 on 2024-05-31 22:41
from django.db import migrations, models
from django.utils import timezone
class Migration(migrations.Migration):
dependencies = [
('authentication', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='discorduser',
name='token_expires',
field=models.DateTimeField(default=timezone.now, help_text='when to request a new access token.', verbose_name='token expires'),
preserve_default=False,
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-01 16:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0002_discorduser_token_expires'),
]
operations = [
migrations.AddField(
model_name='discorduser',
name='refresh_token',
field=models.CharField(default='1', help_text='token for the application to request a new access token.', max_length=100, verbose_name='refresh token'),
preserve_default=False,
),
]

View File

@ -159,3 +159,25 @@ class DiscordUser(PermissionsMixin):
self.refresh_token=raw["refresh_token"]
self.save(force_update=True)
class ServerMember(models.Model):
"""
A link table, connecting users to servers, and storing their server-specific data.
"""
id = models.AutoField(primary_key=True)
user = models.ForeignKey(to=DiscordUser, on_delete=models.CASCADE)
server = models.ForeignKey(to="home.Server", on_delete=models.CASCADE)
permissions = models.CharField(max_length=32)
is_owner = models.BooleanField(default=False)
def __str__(self):
return f"{self.server.name} · {self.user}"
def has_permission(self, flag: int) -> bool:
"""Check that the member has a givern permission.
https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
"""
permissions_int = int(self.permissions)
return (permissions_int & flag) == flag

View File

@ -1,4 +1,4 @@
{% extends "layouts/base.html" %}
{% extends "base.html" %}
{% load static %}
{% block title %} - Login {% endblock title %}
@ -7,8 +7,8 @@
{% block stylesheets %}{% endblock stylesheets %}
{% block content %}
<div class="container-lg px-0 h-100">
<div class="d-flex flex-nowrap h-100 border-start border-end position-relative">
<div class="px-0 h-100">
<div class="d-flex flex-nowrap h-100 position-relative">
<div class="modal d-block position-absolute">
<div class="modal-dialog modal-dialog-centered">

View File

@ -3,17 +3,12 @@
from django.urls import path
from django.contrib.auth.views import LogoutView
from .views import DiscordLoginAction, DiscordLoginRedirect, Login, GuildsView, GuildChannelsView, SaveGuildView
from .views import DiscordLoginAction, DiscordLoginRedirect, Login
urlpatterns = [
path("login/", Login.as_view(), name="login"),
path("oauth2/login/", DiscordLoginAction.as_view(), name="discord-login"),
path("oauth2/login/redirect/", DiscordLoginRedirect.as_view(), name="discord-login-redirect"),
path("logout/", LogoutView.as_view(), name="logout"),
path("guilds/", GuildsView.as_view(), name="guilds"),
path("channels/", GuildChannelsView.as_view(), name="channels"),
path("save-guild/", SaveGuildView.as_view(), name="save-guild")
path("logout/", LogoutView.as_view(), name="logout")
]

View File

@ -49,7 +49,8 @@ class DiscordLoginRedirect(View):
discord_user = authenticate(request, discord_user_data=raw_user_data)
login(request, discord_user)
return redirect("home:index")
redirect_url = request.GET.get("redirect") or "home:index"
return redirect(redirect_url)
def exchange_code(self, code: str) -> dict:
"""
@ -118,34 +119,6 @@ class Login(TemplateView):
template_name = "accounts/login.html"
class GuildsView(View):
def get(self, request, *args, **kwargs):
response = requests.get(
url=f"{settings.DISCORD_API_URL}/users/@me/guilds",
headers={"Authorization": f"Bearer {request.user.access_token}"}
)
content = response.json()
status = response.status_code
if status != 200:
log.warning("Bad status code getting guilds: %s", status)
return JsonResponse(content, safe=False, status=status)
valid_guilds = [guild for guild in response.json() if self._has_permissions(guild)]
return JsonResponse(valid_guilds, safe=False, status=status)
def _has_permissions(self, guild):
permissions = guild["permissions"]
is_owner = guild["owner"]
return (int(permissions) & 1 << 3) == 1 << 3 or is_owner
class GuildChannelsView(View):
def get(self, request, *args, **kwargs):
@ -157,9 +130,4 @@ class GuildChannelsView(View):
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
)
return JsonResponse(response.json(), safe=False)
class SaveGuildView(View):
pass
return JsonResponse(response.json(), status=response.status_code, safe=False)

View File

@ -2,52 +2,119 @@
from django.contrib import admin
from .models import Subscription, SavedGuilds, Filter, SubChannel, TrackedContent, ArticleMutator, GuildSettings
from .models import (
Server,
ContentFilter,
MessageMutator,
MessageStyle,
DiscordChannel,
Subscription,
Content,
UniqueContentRule
)
@admin.register(Server)
class ServerAdmin(admin.ModelAdmin):
list_display = [
"id",
"name",
"icon_hash",
"is_bot_operational",
"active"
]
list_display_links = ["id"]
@admin.register(ContentFilter)
class ContentFilterAdmin(admin.ModelAdmin):
list_display = [
"id",
"name",
"server",
"match",
"matching_algorithm",
"is_insensitive",
"is_whitelist"
]
list_display_links = ["name"]
@admin.register(MessageMutator)
class MessageMutatorAdmin(admin.ModelAdmin):
list_display = [
"id",
"name",
"value"
]
list_display_links = ["name"]
@admin.register(MessageStyle)
class MessageStyleAdmin(admin.ModelAdmin):
list_display = [
"id",
"name",
"server",
"is_embed",
"colour",
"is_hyperlinked",
"show_author",
"show_timestamp",
"show_images",
"fetch_images",
"title_mutator",
"description_mutator",
"auto_created"
]
list_display_links = ["name"]
@admin.register(DiscordChannel)
class DiscordChannelAdmin(admin.ModelAdmin):
list_display = [
"id",
"name",
"server",
"is_nsfw"
]
list_display_links = ["name"]
@admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin):
class Subscription(admin.ModelAdmin):
list_display = [
"id", "name", "url", "guild_id",
"creation_datetime", "active"
"id",
"name",
"server",
"url",
"created_at",
"updated_at",
"extra_notes",
"active",
"message_style"
]
list_display_links = ["name"]
@admin.register(SubChannel)
class SubChannelAdmin(admin.ModelAdmin):
@admin.register(Content)
class ContentAdmin(admin.ModelAdmin):
list_display = [
"id", "channel_id", "subscription"
"id",
"subscription",
"item_id",
"item_guid",
"item_url",
"item_title",
"item_content_hash"
]
@admin.register(Filter)
class FilterAdmin(admin.ModelAdmin):
@admin.register(UniqueContentRule)
class UniqueContentRule(admin.ModelAdmin):
list_display = [
"id", "name", "guild_id"
]
@admin.register(TrackedContent)
class TrackedContentAdmin(admin.ModelAdmin):
list_display = [
"guid", "title", "url", "subscription", "blocked", "creation_datetime"
]
@admin.register(SavedGuilds)
class SavedGuildAdmin(admin.ModelAdmin):
list_display = [
"id", "name", "icon"
]
@admin.register(ArticleMutator)
class ArticleMutatorAdmin(admin.ModelAdmin):
list_display = [
"id", "name", "value"
]
@admin.register(GuildSettings)
class GuildSettingsAdmin(admin.ModelAdmin):
list_display = [
"id", "guild_id", "default_embed_colour", "active"
"id",
"name",
"value"
]
list_display_links = ["name"]

View File

@ -1,8 +1,7 @@
# Generated by Django 5.0.4 on 2024-06-17 01:11
# Generated by Django 5.0.4 on 2024-09-24 14:05
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
@ -11,53 +10,70 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Filter',
name='MessageMutator',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('value', models.CharField(max_length=32)),
],
),
migrations.CreateModel(
name='Server',
fields=[
('id', models.PositiveIntegerField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=128)),
('icon_hash', models.CharField(blank=True, max_length=128, null=True)),
('active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='UniqueContentRule',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('value', models.CharField(max_length=32)),
],
),
migrations.CreateModel(
name='MessageStyle',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('is_embed', models.BooleanField(default=True)),
('is_hyperlinked', models.BooleanField()),
('show_author', models.BooleanField()),
('show_timestamp', models.BooleanField()),
('show_images', models.BooleanField()),
('fetch_images', models.BooleanField()),
('description_mutator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='desc_mutated_messagestyle', to='home.messagemutator')),
('title_mutator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='title_mutated_messagestyle', to='home.messagemutator')),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
],
),
migrations.CreateModel(
name='ContentFilter',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=32)),
('keywords', models.CharField(blank=True, max_length=128, null=True)),
('regex', models.CharField(blank=True, max_length=128, null=True)),
('whitelist', models.BooleanField(default=False)),
('guild_id', models.CharField(max_length=128)),
('match', models.CharField(max_length=256)),
('matching_algorithm', models.PositiveIntegerField(choices=[(0, 'None'), (1, 'Any: Item contains any of these words (space separated)'), (2, 'All: Item contains all of these words (space separated)'), (3, 'Exact: Item contains this string'), (4, 'Regular expression: Item matches this regex'), (5, 'Fuzzy: Item contains a word similar to this word')])),
('is_insensitive', models.BooleanField()),
('is_whitelist', models.BooleanField()),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
],
options={
'verbose_name': 'filter',
'verbose_name_plural': 'filters',
'get_latest_by': 'id',
},
),
migrations.CreateModel(
name='SavedGuilds',
name='BotLogicLogs',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('guild_id', models.CharField(help_text='Discord snowflake ID for the represented guild.', max_length=128, verbose_name='guild id')),
('name', models.CharField(help_text='Name of the represented guild.', max_length=128)),
('icon', models.CharField(help_text="Hash for the represented guild's icon.", max_length=128)),
('permissions', models.CharField(help_text='Guild permissions for the user who added this instance.', max_length=64)),
('owner', models.BooleanField(default=False, help_text="Does the 'added by' user own this guild?")),
('level', models.CharField(max_length=32)),
('message', models.CharField(max_length=256)),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
],
options={
'verbose_name': 'saved guild',
'verbose_name_plural': 'saved guilds',
'get_latest_by': '-creation_datetime',
},
),
migrations.CreateModel(
name='SubChannel',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('channel_id', models.CharField(help_text='Discord snowflake ID for the represented Channel.', max_length=128, verbose_name='channel id')),
],
options={
'verbose_name': 'SubChannel',
'verbose_name_plural': 'SubChannels',
'get_latest_by': 'id',
},
),
migrations.CreateModel(
name='Subscription',
@ -65,76 +81,26 @@ class Migration(migrations.Migration):
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=32)),
('url', models.URLField()),
('guild_id', models.CharField(max_length=128)),
('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('extra_notes', models.CharField(blank=True, max_length=250, null=True)),
('uwuify', models.BooleanField(default=False)),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('updated_at', models.DateTimeField(default=django.utils.timezone.now)),
('extra_notes', models.CharField(blank=True, default='', max_length=250)),
('active', models.BooleanField(default=True)),
('filters', models.ManyToManyField(blank=True, to='home.contentfilter')),
('message_style', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='home.messagestyle')),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server')),
('unique_rules', models.ManyToManyField(to='home.uniquecontentrule')),
],
options={
'verbose_name': 'subscription',
'verbose_name_plural': 'subscriptions',
'get_latest_by': '-creation_datetime',
},
),
migrations.CreateModel(
name='TrackedContent',
name='Content',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('guid', models.CharField(max_length=128)),
('title', models.CharField(max_length=128)),
('url', models.URLField(unique=True)),
('blocked', models.BooleanField(default=False)),
('creation_datetime', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('guild_id', models.CharField(max_length=128)),
('item_id', models.CharField(max_length=1024)),
('item_guid', models.CharField(max_length=1024)),
('item_url', models.CharField(max_length=1024)),
('item_title', models.CharField(max_length=1024)),
('item_content_hash', models.CharField(max_length=1024)),
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.subscription')),
],
options={
'verbose_name': 'tracked contents',
'get_latest_by': '-creation_datetime',
},
),
migrations.AddConstraint(
model_name='filter',
constraint=models.UniqueConstraint(fields=('name', 'guild_id'), name='unique name & guild id pair'),
),
migrations.AddField(
model_name='savedguilds',
name='added_by',
field=models.ForeignKey(help_text='The user who added created this instance.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='added by'),
),
migrations.AddField(
model_name='subscription',
name='filters',
field=models.ManyToManyField(blank=True, to='home.filter'),
),
migrations.AddField(
model_name='subchannel',
name='subscription',
field=models.ForeignKey(help_text='The linked Subscription, must be unique.', on_delete=django.db.models.deletion.CASCADE, to='home.subscription'),
),
migrations.AddField(
model_name='trackedcontent',
name='subscription',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.subscription'),
),
migrations.AddConstraint(
model_name='savedguilds',
constraint=models.UniqueConstraint(fields=('added_by', 'guild_id'), name='unique added_by & guild_id pair'),
),
migrations.AddConstraint(
model_name='subscription',
constraint=models.UniqueConstraint(fields=('name', 'guild_id'), name='unique name & server pair'),
),
migrations.AddConstraint(
model_name='subchannel',
constraint=models.UniqueConstraint(fields=('channel_id', 'subscription'), name='unique channel id and subscription pair'),
),
migrations.AddConstraint(
model_name='trackedcontent',
constraint=models.UniqueConstraint(fields=('guid', 'guild_id'), name='unique guid & guild_id pair'),
),
migrations.AddConstraint(
model_name='trackedcontent',
constraint=models.UniqueConstraint(fields=('url', 'guild_id'), name='unique url & guild_id pair'),
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-17 01:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ArticleMutator',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('value', models.CharField(max_length=32)),
],
),
]

View File

@ -0,0 +1,47 @@
# Generated by Django 5.0.4 on 2024-09-24 14:06
# This migration was manually configured to create predefined data by corbz
from django.db import migrations
def add_mutators(apps, schema_editor):
model = apps.get_model("home", "MessageMutator")
model.objects.create(name="Uwuify", value="uwuify")
model.objects.create(name="Uwuify (NSFW)", value="uwuify_nsfw")
model.objects.create(name="Gothic Script", value="gothic")
model.objects.create(name="Emoji Substitute", value="emj_substitute")
model.objects.create(name="Zalgo", value="zalgo")
model.objects.create(name="Morse Code", value="morse_code")
model.objects.create(name="Binary", value="to_bin")
model.objects.create(name="Hexadecimal", value="to_hex")
model.objects.create(name="Remove Vowels", value="rm_vowels")
model.objects.create(name="Double Characters", value="dbl_chars")
model.objects.create(name="Small Case", value="small_caps")
model.objects.create(name="L33t Sp34k", value="leet_speak")
model.objects.create(name="Pig Latin", value="pig_latin")
model.objects.create(name="Upside Down", value="flipped")
model.objects.create(name="All Reversed", value="reverse_text")
model.objects.create(name="Reversed Words", value="backwards_words")
model.objects.create(name="Shuffle Words", value="shuffle_words")
model.objects.create(name="Random Case", value="rnd_case")
model.objects.create(name="Gibberish", value="gibberish")
model.objects.create(name="Shakespearean", value="shakespear")
def add_rules(apps, schema_editor):
model = apps.get_model("home", "UniqueContentRule")
model.objects.create(name="GUID", value="GUID")
model.objects.create(name="ID", value="ID")
model.objects.create(name="URL", value="URL")
model.objects.create(name="Title", value="TITLE")
model.objects.create(name="Content Hash", value="CONTENT")
class Migration(migrations.Migration):
dependencies = [
('home', '0001_initial'),
]
operations = [
migrations.RunPython(add_mutators),
migrations.RunPython(add_rules)
]

View File

@ -0,0 +1,67 @@
# Generated by Django 5.0.4 on 2024-09-27 12:52
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0002_predefined_data'),
]
operations = [
migrations.AlterModelOptions(
name='botlogiclogs',
options={'get_latest_by': 'id', 'verbose_name': 'bot logic log', 'verbose_name_plural': 'bot logic logs'},
),
migrations.AlterModelOptions(
name='content',
options={'get_latest_by': 'id', 'verbose_name': 'content', 'verbose_name_plural': 'content'},
),
migrations.AlterModelOptions(
name='contentfilter',
options={'get_latest_by': 'id', 'verbose_name': 'filter', 'verbose_name_plural': 'filters'},
),
migrations.AlterModelOptions(
name='messagemutator',
options={'get_latest_by': 'id', 'verbose_name': 'message mutator', 'verbose_name_plural': 'message mutators'},
),
migrations.AlterModelOptions(
name='messagestyle',
options={'get_latest_by': 'id', 'verbose_name': 'message style', 'verbose_name_plural': 'message styles'},
),
migrations.AlterModelOptions(
name='server',
options={'get_latest_by': 'name', 'verbose_name': 'server', 'verbose_name_plural': 'servers'},
),
migrations.AlterModelOptions(
name='subscription',
options={'get_latest_by': 'updated_at', 'verbose_name': 'subscription', 'verbose_name_plural': 'subscriptions'},
),
migrations.AlterModelOptions(
name='uniquecontentrule',
options={'get_latest_by': 'id', 'verbose_name': 'unique content rule', 'verbose_name_plural': 'unique content rules'},
),
migrations.AddField(
model_name='messagestyle',
name='colour',
field=models.CharField(default='3498db', max_length=6),
),
migrations.AddField(
model_name='subscription',
name='publish_threshold',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='messagestyle',
name='is_embed',
field=models.BooleanField(),
),
migrations.AlterField(
model_name='messagestyle',
name='server',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='home.server'),
),
]

View File

@ -1,37 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-17 01:23
from django.db import migrations
def add_mutators(apps, schema_editor):
ArticleMutator = apps.get_model("home", "ArticleMutator")
ArticleMutator.objects.create(name="Uwuify", value="uwuify")
ArticleMutator.objects.create(name="Uwuify (NSFW)", value="uwuify_nsfw")
ArticleMutator.objects.create(name="Gothic Script", value="gothic")
ArticleMutator.objects.create(name="Emoji Substitute", value="emj_substitute")
ArticleMutator.objects.create(name="Zalgo", value="zalgo")
ArticleMutator.objects.create(name="Morse Code", value="morse_code")
ArticleMutator.objects.create(name="Binary", value="to_bin")
ArticleMutator.objects.create(name="Hexadecimal", value="to_hex")
ArticleMutator.objects.create(name="Remove Vowels", value="rm_vowels")
ArticleMutator.objects.create(name="Double Characters", value="dbl_chars")
ArticleMutator.objects.create(name="Small Case", value="small_caps")
ArticleMutator.objects.create(name="L33t Sp34k", value="leet_speak")
ArticleMutator.objects.create(name="Pig Latin", value="pig_latin")
ArticleMutator.objects.create(name="Upside Down", value="flipped")
ArticleMutator.objects.create(name="All Reversed", value="reverse_text")
ArticleMutator.objects.create(name="Reversed Words", value="backwards_words")
ArticleMutator.objects.create(name="Shuffle Words", value="shuffle_words")
ArticleMutator.objects.create(name="Random Case", value="rnd_case")
ArticleMutator.objects.create(name="Gibberish", value="gibberish")
ArticleMutator.objects.create(name="Shakespearean", value="shakespear")
class Migration(migrations.Migration):
dependencies = [
('home', '0002_articlemutator'),
]
operations = [
migrations.RunPython(add_mutators)
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.4 on 2024-09-27 15:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0003_alter_botlogiclogs_options_alter_content_options_and_more'),
]
operations = [
migrations.AddField(
model_name='messagestyle',
name='name',
field=models.CharField(default='My Message Style', max_length=32),
preserve_default=False,
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-17 01:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('home', '0003_initial_mutator_data'),
]
operations = [
migrations.RemoveField(
model_name='subscription',
name='uwuify',
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.0.4 on 2024-10-01 12:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0004_messagestyle_name'),
]
operations = [
migrations.CreateModel(
name='DiscordChannel',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('channel_id', models.BigIntegerField()),
('name', models.CharField(max_length=128)),
],
),
migrations.AddField(
model_name='subscription',
name='channels',
field=models.ManyToManyField(blank=True, related_name='subscriptions', to='home.discordchannel'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-17 01:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0004_remove_subscription_uwuify'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='mutators',
field=models.ManyToManyField(blank=True, to='home.articlemutator'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-18 11:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0005_subscription_mutators'),
]
operations = [
migrations.AlterField(
model_name='trackedcontent',
name='guid',
field=models.CharField(max_length=256),
),
migrations.AlterField(
model_name='trackedcontent',
name='title',
field=models.CharField(max_length=728),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.0.4 on 2024-10-01 20:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0005_discordchannel_subscription_channels'),
]
operations = [
migrations.RemoveField(
model_name='discordchannel',
name='name',
),
migrations.AddField(
model_name='messagestyle',
name='auto_created',
field=models.BooleanField(blank=True, default=False),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 5.0.4 on 2024-10-02 20:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0006_remove_discordchannel_name_messagestyle_auto_created'),
]
operations = [
migrations.AddField(
model_name='discordchannel',
name='server',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.server'),
preserve_default=False,
),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-25 08:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0006_alter_trackedcontent_guid_alter_trackedcontent_title'),
]
operations = [
migrations.RemoveField(
model_name='subscription',
name='mutators',
),
migrations.AddField(
model_name='subscription',
name='article_desc_mutators',
field=models.ManyToManyField(blank=True, related_name='desc_mutated_subscriptions', to='home.articlemutator'),
),
migrations.AddField(
model_name='subscription',
name='article_title_mutators',
field=models.ManyToManyField(blank=True, related_name='title_mutated_subscriptions', to='home.articlemutator'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.4 on 2024-10-02 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0007_discordchannel_server'),
]
operations = [
migrations.AddField(
model_name='discordchannel',
name='name',
field=models.CharField(default='placeholder name', max_length=128),
preserve_default=False,
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-25 09:41
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0007_remove_subscription_mutators_and_more'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='article_fetch_limit',
field=models.PositiveSmallIntegerField(default=10, validators=[django.core.validators.MaxValueValidator(1), django.core.validators.MinValueValidator(10)]),
),
migrations.AddField(
model_name='subscription',
name='reset_article_fetch_limit',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 5.0.4 on 2024-10-02 21:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0008_discordchannel_name'),
]
operations = [
migrations.RemoveField(
model_name='discordchannel',
name='channel_id',
),
migrations.RemoveField(
model_name='discordchannel',
name='name',
),
migrations.AddField(
model_name='discordchannel',
name='is_nsfw',
field=models.BooleanField(default=False),
preserve_default=False,
),
migrations.AlterField(
model_name='discordchannel',
name='id',
field=models.PositiveIntegerField(primary_key=True, serialize=False),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.4 on 2024-06-26 13:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0008_subscription_article_fetch_limit_and_more'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='embed_colour',
field=models.CharField(default='3498db', max_length=6),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-02 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0009_subscription_embed_colour'),
]
operations = [
migrations.AlterField(
model_name='subscription',
name='embed_colour',
field=models.CharField(blank=True, default='3498db', max_length=6),
),
migrations.AlterField(
model_name='trackedcontent',
name='url',
field=models.URLField(),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.4 on 2024-10-02 21:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0009_remove_discordchannel_channel_id_and_more'),
]
operations = [
migrations.AddField(
model_name='discordchannel',
name='name',
field=models.CharField(default='placeholder', max_length=128),
preserve_default=False,
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-02 12:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0010_alter_subscription_embed_colour_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='trackedcontent',
name='unique guid & guild_id pair',
),
migrations.RemoveConstraint(
model_name='trackedcontent',
name='unique url & guild_id pair',
),
migrations.RenameField(
model_name='trackedcontent',
old_name='guild_id',
new_name='channel_id',
),
migrations.AddConstraint(
model_name='trackedcontent',
constraint=models.UniqueConstraint(fields=('guid', 'channel_id'), name='unique guid & guild_id pair'),
),
migrations.AddConstraint(
model_name='trackedcontent',
constraint=models.UniqueConstraint(fields=('url', 'channel_id'), name='unique url & guild_id pair'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-10-11 16:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0010_discordchannel_name'),
]
operations = [
migrations.AddField(
model_name='server',
name='is_bot_operational',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-10-11 16:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0011_server_is_bot_operational'),
]
operations = [
migrations.AlterField(
model_name='server',
name='is_bot_operational',
field=models.BooleanField(default=None, null=True),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-05 13:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0011_remove_trackedcontent_unique_guid_guild_id_pair_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='trackedcontent',
name='unique guid & guild_id pair',
),
migrations.RemoveConstraint(
model_name='trackedcontent',
name='unique url & guild_id pair',
),
migrations.AddField(
model_name='subscription',
name='article_fetch_image',
field=models.BooleanField(default=True, help_text='Will the resulting article have an image?'),
),
migrations.AddConstraint(
model_name='trackedcontent',
constraint=models.UniqueConstraint(fields=('guid', 'channel_id'), name='unique guid & channel_id pair'),
),
migrations.AddConstraint(
model_name='trackedcontent',
constraint=models.UniqueConstraint(fields=('url', 'channel_id'), name='unique url & channel_id pair'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-10-14 22:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0012_alter_server_is_bot_operational'),
]
operations = [
migrations.AlterField(
model_name='subscription',
name='channels',
field=models.ManyToManyField(related_name='subscriptions', to='home.discordchannel'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-10 09:08
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0012_remove_trackedcontent_unique_guid_guild_id_pair_and_more'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='published_theshold',
field=models.DateField(blank=True, default=django.utils.timezone.now),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-10-14 23:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0013_alter_subscription_channels'),
]
operations = [
migrations.AlterField(
model_name='discordchannel',
name='id',
field=models.PositiveBigIntegerField(primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='server',
name='id',
field=models.PositiveBigIntegerField(primary_key=True, serialize=False),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-10 09:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('home', '0013_subscription_published_theshold'),
]
operations = [
migrations.RenameField(
model_name='subscription',
old_name='published_theshold',
new_name='published_threshold',
),
]

View File

@ -1,67 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-10 13:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0014_rename_published_theshold_subscription_published_threshold'),
]
operations = [
migrations.AlterModelOptions(
name='filter',
options={'ordering': ('name',)},
),
migrations.RemoveField(
model_name='filter',
name='keywords',
),
migrations.RemoveField(
model_name='filter',
name='regex',
),
migrations.RemoveField(
model_name='filter',
name='whitelist',
),
migrations.RemoveField(
model_name='subscription',
name='article_fetch_limit',
),
migrations.RemoveField(
model_name='subscription',
name='reset_article_fetch_limit',
),
migrations.AddField(
model_name='filter',
name='is_insensitive',
field=models.BooleanField(default=True, verbose_name='is insensitive'),
),
migrations.AddField(
model_name='filter',
name='is_whitelist',
field=models.BooleanField(default=False, verbose_name='is whitelist'),
),
migrations.AddField(
model_name='filter',
name='match',
field=models.CharField(blank=True, max_length=256, verbose_name='match'),
),
migrations.AddField(
model_name='filter',
name='matching_algorithm',
field=models.PositiveIntegerField(choices=[(0, 'None'), (1, 'Any word'), (2, 'All words'), (3, 'Exact match'), (4, 'Regular expression'), (5, 'Fuzzy word'), (6, 'Automatic')], default=1, verbose_name='matching algorithm'),
),
migrations.AlterField(
model_name='filter',
name='guild_id',
field=models.CharField(max_length=128, verbose_name='guild id'),
),
migrations.AlterField(
model_name='filter',
name='name',
field=models.CharField(max_length=128, verbose_name='name'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.4 on 2024-10-15 18:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0014_alter_discordchannel_id_alter_server_id'),
]
operations = [
migrations.AlterField(
model_name='subscription',
name='message_style',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='home.messagestyle'),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-10 19:19
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0015_alter_filter_options_remove_filter_keywords_and_more'),
]
operations = [
migrations.AlterField(
model_name='filter',
name='matching_algorithm',
field=models.PositiveIntegerField(choices=[(0, 'None'), (1, 'Any: Item contains any of these words (space separated)'), (2, 'All: Item contains all of these words (space separated)'), (3, 'Exact: Item contains this string'), (4, 'Regular expression: Item matches this regex'), (5, 'Fuzzy: Item contains a word similar to this word')], default=1, verbose_name='matching algorithm'),
),
migrations.AlterField(
model_name='subscription',
name='published_threshold',
field=models.DateTimeField(blank=True, default=django.utils.timezone.now),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-11 21:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0016_alter_filter_matching_algorithm_and_more'),
]
operations = [
migrations.AddField(
model_name='trackedcontent',
name='message_id',
field=models.CharField(max_length=128),
preserve_default=False,
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-20 18:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0017_trackedcontent_message_id'),
]
operations = [
migrations.AddField(
model_name='subchannel',
name='channel_name',
field=models.CharField(default='placeholder-channel-name', help_text='Name of the represented Channel.', max_length=256, verbose_name='channel name'),
preserve_default=False,
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-23 14:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0018_subchannel_channel_name'),
]
operations = [
migrations.AddField(
model_name='savedguilds',
name='default_embed_colour',
field=models.CharField(blank=True, default='3498db', max_length=6),
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 5.0.4 on 2024-07-23 15:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0019_savedguilds_default_embed_colour'),
]
operations = [
migrations.CreateModel(
name='GuildSettings',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('guild_id', models.CharField(help_text='Discord snowflake ID for the represented guild.', max_length=128, verbose_name='guild id')),
('default_embed_colour', models.CharField(blank=True, default='3498db', max_length=6, verbose_name='default embed colour')),
],
),
]

View File

@ -1,122 +0,0 @@
# Generated by Django 5.0.4 on 2024-08-14 20:41
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0020_guildsettings'),
]
operations = [
migrations.AlterModelOptions(
name='guildsettings',
options={'get_latest_by': 'id', 'verbose_name': 'guild settings', 'verbose_name_plural': 'guild settings'},
),
migrations.RemoveField(
model_name='savedguilds',
name='default_embed_colour',
),
migrations.AddField(
model_name='guildsettings',
name='active',
field=models.BooleanField(default=True, help_text='Subscriptions of inactive guilds will also be treated as inactive', verbose_name='Active'),
),
migrations.AlterField(
model_name='guildsettings',
name='guild_id',
field=models.CharField(help_text='Discord snowflake ID for the represented guild.', max_length=128, unique=True, verbose_name='guild id'),
),
migrations.AlterField(
model_name='savedguilds',
name='id',
field=models.AutoField(primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='subscription',
name='active',
field=models.BooleanField(default=True, verbose_name='Active'),
),
migrations.AlterField(
model_name='subscription',
name='article_fetch_image',
field=models.BooleanField(default=True, help_text='Will the resulting article have an image?', verbose_name='Fetch Article Images'),
),
migrations.AlterField(
model_name='subscription',
name='creation_datetime',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created At'),
),
migrations.AlterField(
model_name='subscription',
name='embed_colour',
field=models.CharField(blank=True, default='3498db', max_length=6, verbose_name='Embed Colour'),
),
migrations.AlterField(
model_name='subscription',
name='extra_notes',
field=models.CharField(blank=True, max_length=250, null=True, verbose_name='Extra Notes'),
),
migrations.AlterField(
model_name='subscription',
name='guild_id',
field=models.CharField(max_length=128, verbose_name='Guild ID'),
),
migrations.AlterField(
model_name='subscription',
name='name',
field=models.CharField(max_length=32, verbose_name='Name'),
),
migrations.AlterField(
model_name='subscription',
name='published_threshold',
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, verbose_name='Published Threshold'),
),
migrations.AlterField(
model_name='subscription',
name='url',
field=models.URLField(verbose_name='URL'),
),
migrations.AlterField(
model_name='trackedcontent',
name='blocked',
field=models.BooleanField(default=False, verbose_name='Blocked'),
),
migrations.AlterField(
model_name='trackedcontent',
name='channel_id',
field=models.CharField(max_length=128, verbose_name='Channel ID'),
),
migrations.AlterField(
model_name='trackedcontent',
name='creation_datetime',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created At'),
),
migrations.AlterField(
model_name='trackedcontent',
name='guid',
field=models.CharField(help_text='RSS provided GUID of the content', max_length=256, verbose_name='GUID'),
),
migrations.AlterField(
model_name='trackedcontent',
name='id',
field=models.AutoField(primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='trackedcontent',
name='message_id',
field=models.CharField(max_length=128, verbose_name='Message ID'),
),
migrations.AlterField(
model_name='trackedcontent',
name='title',
field=models.CharField(max_length=728, verbose_name='Title'),
),
migrations.AlterField(
model_name='trackedcontent',
name='url',
field=models.URLField(verbose_name='URL'),
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 5.0.4 on 2024-08-14 20:46
from django.db import migrations
def create_missing_guild_settings(apps, scheme_editor):
SavedGuilds = apps.get_model("home", "SavedGuilds")
GuildSettings = apps.get_model("home", "GuildSettings")
for saved_guild in SavedGuilds.objects.all():
GuildSettings.objects.get_or_create(guild_id=saved_guild.guild_id)
class Migration(migrations.Migration):
dependencies = [
('home', '0021_alter_guildsettings_options_and_more'),
]
operations = [
migrations.RunPython(create_missing_guild_settings),
]

View File

@ -1,179 +1,55 @@
# -*- encoding: utf-8 -*-
import logging
from pathlib import Path
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.exceptions import ValidationError
from django.db.models.signals import post_save
from django.dispatch import receiver
log = logging.getLogger(__name__)
class GuildSettings(models.Model):
# region Server
class Server(models.Model):
"""
Represents settings for a saved Discord Guild `SavedGuild`.
These objects aren't linked through foreignkey because
`SavedGuild` is user user unique, not Discord Guild unique.
Represents a Discord Server.
Instances of this model are automatically handled, and manual intervension
should be avoided if possible.
"""
id = models.AutoField(primary_key=True)
guild_id = models.CharField(
verbose_name=_("guild id"),
max_length=128,
help_text=_("Discord snowflake ID for the represented guild."),
unique=True
)
default_embed_colour = models.CharField(
verbose_name=_("default embed colour"),
max_length=6,
default="3498db",
blank=True
)
active = models.BooleanField(
verbose_name=_("Active"),
default=True,
help_text=_("Subscriptions of inactive guilds will also be treated as inactive")
)
id = models.PositiveBigIntegerField(primary_key=True)
name = models.CharField(max_length=128)
icon_hash = models.CharField(max_length=128, blank=True, null=True)
is_bot_operational = models.BooleanField(default=None, null=True)
active = models.BooleanField(default=True)
class Meta:
"""
Metadata for the GuildSettings model.
"""
verbose_name = "guild settings"
verbose_name_plural = "guild settings"
get_latest_by = "id"
class SavedGuilds(models.Model):
"""
Represents a saved Discord Guild (aka Server).
These are shown in the UI on the sidebar, and can be selected
to see associated Subscriptions.
"""
id = models.AutoField(_("ID"), primary_key=True)
# Have to use charfield instead of positiveBigIntegerField due to an Sqlite
# issue that rounds down the value
# https://github.com/sequelize/sequelize/issues/9335
guild_id = models.CharField(
verbose_name=_("guild id"),
max_length=128,
help_text=_("Discord snowflake ID for the represented guild.")
)
name = models.CharField(
max_length=128,
help_text=_("Name of the represented guild.")
)
icon = models.CharField(
max_length=128,
help_text=_("Hash for the represented guild's icon.")
)
added_by = models.ForeignKey(
verbose_name=_("added by"),
to="authentication.DiscordUser",
on_delete=models.CASCADE,
help_text=_("The user who added created this instance.")
)
permissions = models.CharField(
max_length=64,
help_text=_("Guild permissions for the user who added this instance.")
)
owner = models.BooleanField(
default=False,
help_text=_("Does the 'added by' user own this guild?")
)
class Meta:
"""
Metadata for the SavedGuilds Model.
"""
verbose_name = "saved guild"
verbose_name_plural = "saved guilds"
get_latest_by = "-creation_datetime"
constraints = [
# Prevent servers from having subscriptions with duplicate names
models.UniqueConstraint(
fields=["added_by", "guild_id"],
name="unique added_by & guild_id pair"
)
]
def __str__(self) -> str:
return self.name
verbose_name = "server"
verbose_name_plural = "servers"
get_latest_by = "name"
@property
def settings(self):
return GuildSettings.objects.get(guild_id=self.guild_id)
def icon_url(self):
return f"https://cdn.discordapp.com/icons/{self.id}/{self.icon_hash}.webp?size=80"
def save(self, *args, **kwargs):
GuildSettings.objects.get_or_create(guild_id=self.guild_id)
super().save(*args, **kwargs)
def __str__(self):
return self.name
class SubChannel(models.Model):
# region Content Filter
class ContentFilter(models.Model):
"""
Represents a Discord TextChannel, saved against a Subscription.
SubChannels are used as targets to send content from Subscriptions.
Filters for the content produced by Subscriptions.
Owned by the related server.
"""
id = models.AutoField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE)
# Have to use charfield instead of positiveBigIntegerField due to an Sqlite
# issue that rounds down the value
# https://github.com/sequelize/sequelize/issues/9335
channel_id = models.CharField(
verbose_name=_("channel id"),
max_length=128,
help_text=_("Discord snowflake ID for the represented Channel.")
)
channel_name = models.CharField(
verbose_name=_("channel name"),
max_length=256,
help_text=_("Name of the represented Channel.")
)
subscription = models.ForeignKey(
to="home.Subscription",
on_delete=models.CASCADE,
help_text=_("The linked Subscription, must be unique.")
)
class Meta:
"""
Metadata for the SubChannel Model.
"""
verbose_name = "SubChannel"
verbose_name_plural = "SubChannels"
get_latest_by = "id"
constraints = [
# Prevent servers from having subscriptions with duplicate names
models.UniqueConstraint(
fields=["channel_id", "subscription"],
name="unique channel id and subscription pair")
]
def __str__(self) -> str:
return self.channel_id
# using a brilliant matching model design from paperless-ngx src
class Filter(models.Model):
MATCH_NONE = 0
MATCH_ANY = 1
MATCH_ALL = 2
@ -189,196 +65,240 @@ class Filter(models.Model):
(MATCH_LITERAL, _("Exact: Item contains this string")),
(MATCH_REGEX, _("Regular expression: Item matches this regex")),
(MATCH_FUZZY, _("Fuzzy: Item contains a word similar to this word")),
# (MATCH_AUTO, _("Automatic")),
)
id = models.AutoField(primary_key=True)
name = models.CharField(_("name"), max_length=128)
match = models.CharField(_("match"), max_length=256, blank=True)
matching_algorithm = models.PositiveIntegerField(
_("matching algorithm"),
choices=MATCHING_ALGORITHMS,
default=MATCH_ANY,
)
is_insensitive = models.BooleanField(_("is insensitive"), default=True)
is_whitelist = models.BooleanField(_("is whitelist"), default=False)
# Have to use charfield instead of positiveBigIntegerField due to an Sqlite
# issue that rounds down the value
# https://github.com/sequelize/sequelize/issues/9335
guild_id = models.CharField(_("guild id"), max_length=128)
name = models.CharField(max_length=32)
match = models.CharField(max_length=256, blank=False)
matching_algorithm = models.PositiveIntegerField(choices=MATCHING_ALGORITHMS)
is_insensitive = models.BooleanField()
is_whitelist = models.BooleanField()
class Meta:
ordering = ("name",)
constraints = [
models.UniqueConstraint(
fields=["name", "guild_id"],
name="unique name & guild id pair"
)
]
verbose_name = "filter"
verbose_name_plural = "filters"
get_latest_by = "id"
def __str__(self):
return f"{self.guild_id} - {self.name}"
class Subscription(models.Model):
"""
The Subscription Model.
'Subscription' in the context of PYRSS is an RSS Feed with various settings.
"""
id = models.AutoField(primary_key=True)
name = models.CharField(
_("Name"),
max_length=32,
null=False,
blank=False
)
url = models.URLField(_("URL"))
# NOTE:
# Have to use charfield instead of positiveBigIntegerField due to an Sqlite
# issue that rounds down the value
# https://github.com/sequelize/sequelize/issues/9335
guild_id = models.CharField(
_("Guild ID"),
max_length=128
)
creation_datetime = models.DateTimeField(
_("Created At"),
default=timezone.now,
editable=False
)
extra_notes = models.CharField(
_("Extra Notes"),
max_length=250,
null=True,
blank=True,
)
filters = models.ManyToManyField(to="home.Filter", blank=True)
article_title_mutators = models.ManyToManyField(
to="home.ArticleMutator",
related_name="title_mutated_subscriptions",
blank=True,
)
article_desc_mutators = models.ManyToManyField(
to="home.ArticleMutator",
related_name="desc_mutated_subscriptions",
blank=True,
)
embed_colour = models.CharField(
_("Embed Colour"),
max_length=6,
default="3498db",
blank=True
)
published_threshold = models.DateTimeField(_("Published Threshold"), default=timezone.now, blank=True)
article_fetch_image = models.BooleanField(
_("Fetch Article Images"),
default=True,
help_text="Will the resulting article have an image?"
)
active = models.BooleanField(_("Active"), default=True)
class Meta:
"""
Metadata for the Subscription Model.
"""
verbose_name = "subscription"
verbose_name_plural = "subscriptions"
get_latest_by = "-creation_datetime"
constraints = [
# Prevent servers from having subscriptions with duplicate names
models.UniqueConstraint(fields=["name", "guild_id"], name="unique name & server pair")
]
@property
def channels_count(self) -> int:
"""
Returns the number of 'SubChannel' objects assocaited
with this subscription.
"""
return len(SubChannel.objects.filter(subscription=self))
def save(self, *args, **kwargs):
new_text = "New " if self._state.adding else ""
log.debug("%sSubscription Saved %s", new_text, self.id)
super().save(*args, **kwargs)
def __str__(self) -> str:
return self.name
class TrackedContent(models.Model):
# region Message Mutator
class MessageMutator(models.Model):
"""
Tracked Content Model
'Tracked Content' identifies articles and tracks them being sent.
This is used to ensure duplicate articles aren't sent in feeds.
Mutators to be applied via the Bot.
Instances of this model are predefined via migrations.
Manual editing should be avoided at all costs!
"""
id = models.AutoField(_("ID"), primary_key=True)
guid = models.CharField(
_("GUID"),
max_length=256,
help_text=_("RSS provided GUID of the content")
)
title = models.CharField(_("Title"), max_length=728)
url = models.URLField(_("URL"))
subscription = models.ForeignKey(to=Subscription, on_delete=models.CASCADE)
channel_id = models.CharField(_("Channel ID"), max_length=128)
message_id = models.CharField(_("Message ID"), max_length=128)
blocked = models.BooleanField(_("Blocked"), default=False)
creation_datetime = models.DateTimeField(
_("Created At"),
default=timezone.now,
editable=False
)
class Meta:
verbose_name = "tracked content"
verbose_name = "tracked contents"
get_latest_by = "-creation_datetime"
constraints = [
models.UniqueConstraint(fields=["guid", "channel_id"], name="unique guid & channel_id pair"),
models.UniqueConstraint(fields=["url", "channel_id"], name="unique url & channel_id pair")
]
def __str__(self) -> str:
return self.title
class ArticleMutator(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
value = models.CharField(max_length=32)
def __str__(self) -> str:
class Meta:
verbose_name = "message mutator"
verbose_name_plural = "message mutators"
get_latest_by = "id"
def __str__(self):
return self.name
# region Message Style
class MessageStyle(models.Model):
"""
Custom styles to be applied via the Bot.
Owned by the related server.
"""
id = models.AutoField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, null=True, blank=False)
name = models.CharField(max_length=32)
colour = models.CharField(max_length=6, default="3498db")
is_embed = models.BooleanField()
is_hyperlinked = models.BooleanField() # title only
show_author = models.BooleanField()
show_timestamp = models.BooleanField()
show_images = models.BooleanField()
fetch_images = models.BooleanField() # if not included with RSS item
title_mutator = models.ForeignKey(
to=MessageMutator,
related_name="title_mutated_messagestyle",
on_delete=models.SET_NULL,
null=True,
blank=True
)
description_mutator = models.ForeignKey(
to=MessageMutator,
related_name="desc_mutated_messagestyle",
on_delete=models.SET_NULL,
null=True,
blank=True
)
auto_created = models.BooleanField(default=False, blank=True)
class Meta:
verbose_name = "message style"
verbose_name_plural = "message styles"
get_latest_by = "id"
def delete(self, *args, **kwargs):
if self.auto_created:
raise ValidationError("Cannot delete 'MessageStyle' instance with 'auto_created=True'")
# If this style is being used, reset the users to the default style
default_message_style = MessageStyle.objects.get(server=self.server, auto_created=True)
Subscription.objects \
.filter(server=self.server, message_style=self) \
.update(message_style=default_message_style)
super().delete(*args, **kwargs)
def __str__(self):
return self.name
@receiver(post_save, sender=Server)
def create_default_items(sender, instance, created, **kwargs):
if not created:
return
# Create a default message style, so the user can get straight into creating subscriptions
# (subscriptions require a message style to exist)
MessageStyle.objects.create(
server=instance,
name=_("Default Message Style"),
colour="3498db",
is_embed=True,
is_hyperlinked=True,
show_author=True,
show_timestamp=True,
show_images=True,
fetch_images=True,
title_mutator=None,
description_mutator=None,
auto_created=True
)
# region Unique Content Rule
class UniqueContentRule(models.Model):
"""
Definitions for what content should be unique
Instances of this model are predefined via migrations.
Manual editing should be avoided at all costs!
"""
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
value = models.CharField(max_length=32)
class Meta:
verbose_name = "unique content rule"
verbose_name_plural = "unique content rules"
get_latest_by = "id"
def __str__(self):
return self.name
# region Discord Channel
class DiscordChannel(models.Model):
"""
Store limited data on a relevant Channel from Discord, used to indicate
where subscriptions should send content to. Instance creation & deletion
is handled internally, when Subscriptions are modified.
"""
id = models.PositiveBigIntegerField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, blank=False)
name = models.CharField(max_length=128)
is_nsfw = models.BooleanField()
def __str__(self):
return f"#{self.name}"
# region Subscription
class Subscription(models.Model):
"""
These represent RSSFeeds, storing relevant settings for managing them.
Owned by the related server.
"""
id = models.AutoField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, blank=False)
name = models.CharField(max_length=32, blank=False)
url = models.URLField()
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(default=timezone.now)
extra_notes = models.CharField(max_length=250, default="", blank=True)
active = models.BooleanField(default=True)
publish_threshold = models.DateTimeField(default=timezone.now)
channels = models.ManyToManyField(to=DiscordChannel, related_name="subscriptions", blank=False)
filters = models.ManyToManyField(to=ContentFilter, blank=True)
message_style = models.ForeignKey(to=MessageStyle, on_delete=models.SET_NULL, null=True, blank=False)
unique_rules = models.ManyToManyField(to=UniqueContentRule, blank=False)
class Meta:
verbose_name = "subscription"
verbose_name_plural = "subscriptions"
get_latest_by = "updated_at"
def __str__(self):
return self.name
# region Content
class Content(models.Model):
"""
Represents a processed item created from a Subscription.
"""
id = models.AutoField(primary_key=True)
subscription = models.ForeignKey(to=Subscription, on_delete=models.CASCADE)
# 'item_' prefix is to differentiate between the internal identifiers and the stored data
item_id = models.CharField(max_length=1024)
item_guid = models.CharField(max_length=1024)
item_url = models.CharField(max_length=1024)
item_title = models.CharField(max_length=1024)
item_content_hash = models.CharField(max_length=1024)
class Meta:
verbose_name = "content"
verbose_name_plural = "content"
get_latest_by = "id"
def __str__(self):
return f"{self.subscription.name} - {self.id}"
# region Bot Logic Logs
# Relevant logs from the bot logic
class BotLogicLogs(models.Model):
id = models.AutoField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE)
level = models.CharField(max_length=32)
message = models.CharField(max_length=256)
created_at = models.DateTimeField(default=timezone.now, editable=False)
class Meta:
verbose_name = "bot logic log"
verbose_name_plural = "bot logic logs"
get_latest_by = "id"
def __str__(self):
return f"{self.server.name} - {self.id}"

View File

@ -1,18 +1,20 @@
$(document).ready(async function() {
await initSubscriptionTable();
await initFiltersTable();
await initContentTable();
initSubscriptionsModule();
initFiltersModule();
initContentModule();
initMessageStylesModule();
await loadServers();
$("#subscriptionsTab").click();
await loadSavedGuilds();
await loadServerOptions();
});
$(document).on("selectedServerChange", function() {
$("#subscriptionsTab").click();
});
// region Hex Strings
function genHexString(len=6) {
let output = '';
for (let i = 0; i < len; ++i) {
@ -21,6 +23,14 @@ function genHexString(len=6) {
return output;
}
// region DateTime
function isISODateTimeString(value) {
const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$/;
return typeof value === 'string' && isoDatePattern.test(value);
}
// my clone of python's datetime.strftime
function formatStringDate(date, format) {
const padZero = (num, len) => String(num).padStart(len, "0");
@ -76,7 +86,8 @@ function formatStringDate(date, format) {
return format.replace(/%[a-zA-Z%-]/g, match => formatters[match] ? formatters[match](date) : match);
}
// #region Colour Controls
// region Colour Controls
$(".colour-control-picker").on("change", function() {
$(this).closest(".colour-control-group").find(".colour-control-text").val($(this).val());
});
@ -116,18 +127,19 @@ $(document).ready(function() {
label = $(this).attr("data-label");
helpText = $(this).attr("data-helptext");
tabIndex = parseInt($(this).attr("data-tabindex"));
dataField = $(this).attr("data-field");
defaultColour = $(this).attr("data-defaultcolour");
defaultColour = defaultColour ? defaultColour : "#3498db"
defaultColour = defaultColour ? defaultColour : "#3498db";
$(this).replaceWith(`
<label for="${id}Picker" class="form-label">${label}</label>
<div id="${id}" class="input-group">
<input type="color" name="${id}Picker" id="${id}Picker" class="form-control-color input-group-text colour-picker" tabindex="${tabIndex}">
<input type="color" name="${id}Picker" id="${id}Picker" class="form-control-color input-group-text colour-picker rounded-start-1" tabindex="${tabIndex}" data-default="${defaultColour}" data-field="${dataField}">
<input type="text" name="${id}Text" id="${id}Text" class="form-control colour-text" tabindex="${tabIndex + 1}">
<button type="button" class="btn btn-secondary colour-reset" data-bs-toggle="tooltip" data-bs-title="Reset Colour" data-defaultcolour="${defaultColour}" tabindex="${tabIndex + 2}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" class="btn btn-secondary colour-random" data-bs-toggle="tooltip" data-bs-title="Random Colour" tabindex="${tabIndex + 3}">
<button type="button" class="btn btn-secondary rounded-end-1 colour-random" data-bs-toggle="tooltip" data-bs-title="Random Colour" tabindex="${tabIndex + 3}">
<i class="bi bi-dice-5"></i>
</button>
</div>
@ -155,38 +167,94 @@ $(document).ready(function() {
});
});
async function confirmationModal(title, bodyText, style, acceptFunc, declineFunc) {
let $modal = $("#confirmationModal");
// Ensure valid style and apply it to the confirm button
if (!["danger", "success", "warning", "info", "primary", "secondary"].includes(style)) {
throw new Error(`${style} is not a valid style`);
// region Log Error
function logError(error) {
if (error instanceof Error) {
// Logs typical error properties like message and stack
console.error({
message: error.message,
stack: error.stack,
name: error.name,
});
} else if (typeof error === 'object' && error !== null) {
// Try to stringify if it's an object
try {
console.error(JSON.stringify(error, null, 2));
} catch (stringifyError) {
console.error('Could not stringify the error:', error);
}
} else {
// Fallback for any other types (string, number, etc.)
console.error('Error:', error);
}
$modal.find(".modal-confirm-btn").addClass(`btn-${style}`);
$modal.find(".modal-title").text(title);
$modal.find(".modal-body > p").html(bodyText);
$modal.find(".modal-confirm-btn").off("click").on("click", async function(e) {
await acceptFunc()
$modal.modal("hide");
});
$modal.find(".modal-dismiss-btn").off("click").on("click", async function(e) {
if (declineFunc) await declineFunc();
$modal.modal("hide");
});
$modal.modal("show");
}
function arrayToHtmlList(array, bold=false) {
$ul = $("<ul>").addClass("mb-0");
array.forEach(item => {
let $li = $("<li>");
$ul.append(bold ? $li.append($("<b>").text(item)) : $li.text(item));
});
// region Sidebar Visibility
return $ul;
}
var _sidebarVisible = $(".sidebar").hasClass("visible"); // Applicable for smaller screens ONLY
const getSidebarVisibility = () => _sidebarVisible;
const setSidebarVisibility = show => {
// Must always show if the sidebar is pinned
show = getSidebarPinned() ? true : show;
_sidebarVisible = show;
if (show) {
$(".sidebar").addClass("visible");
$(".sidebar-backdrop").show();
return;
}
$(".sidebar").removeClass("visible");
$(".sidebar-backdrop").hide();
}
const toggleSidebarVisibility = () => setSidebarVisibility(!getSidebarVisibility());
// Trigger an update to set the backdrop
$(document).ready(() => setSidebarVisibility(getSidebarVisibility()));
// User controls for sidebar visibility
$(".reveal-sidebar-btn").on("click", toggleSidebarVisibility);
$(".sidebar .btn-close").on("click", () => setSidebarVisibility(false));
$(".sidebar-backdrop").on("click", () => setSidebarVisibility(false));
// Prevent sidebar from opening if the screen becomes larger then smaller again, while it's visible
$(window).on('resize', () => {
const sidebarVisibility = getSidebarVisibility();
// If the sidebar expands to be permanently visible, remove the pin & open
// effects, so that it collapses again when shrinking the screen.
//
// Can't pass conditional directly, causes flickering effect
if (sidebarVisibility && $(window).width() > 992) {
setSidebarPinned(false);
setSidebarVisibility(false);
}
// Close the theme menu when the sidebar collapses offscreen
else if (!sidebarVisibility && $(window).width() <= 992) {
$(".js-themeMenuBtn").dropdown("hide");
}
});
// region Sidebar Pin
var _sidebarPinned = $(".js-pinSidebar").hasClass("active");
const getSidebarPinned = () => _sidebarPinned;
const setSidebarPinned = pin => {
_sidebarPinned = pin;
// Show button as active or not
const btn = $(".js-pinSidebar");
pin ? btn.addClass("active") : btn.removeClass("active");
$(".sidebar .btn-close").prop("disabled", pin);
$(".reveal-sidebar-btn").prop("disabled", pin);
}
const toggleSidebarPinned = () => setSidebarPinned(!getSidebarPinned());
// User controls for pinning the sidebar
$(".js-pinSidebar").on("click", toggleSidebarPinned);

View File

@ -0,0 +1,85 @@
const validateBootstrapStyle = style => {
if (!["danger", "success", "warning", "info", "primary", "secondary"].includes(style)) {
throw new Error(`${style} is not a valid style`);
}
return true;
}
const arrayToHtmlList = (array, bold=false) => {
$ul = $("<ul>").addClass("mb-0");
array.forEach(item => {
let $li = $("<li>");
$ul.append(bold ? $li.append($("<b>").text(item)) : $li.text(item));
});
return $ul;
}
const createModal = async (options = {}) => {
let $modal = $("#customModal");
let defaults = {
title: "Modal Title",
texts: [
{
content: "This is a modal",
html: false
}
],
buttons: [
{
text: "Okay",
className: "btn-primary",
iconClass: "",
tabIndex: 2,
closeModal: true,
onClick: null
}
]
};
let settings = { ...defaults, ...options };
$modal.find(".modal-title").text(settings.title);
// Texts
let $body = $modal.find(".modal-body").empty();
settings.texts.forEach((text, idx) => {
if (text.html) {
$body.append(text.content);
return;
}
let $para = $("<p>", {
text: text.content,
class: idx + 1 === settings.texts.length ? "mb-0" : ""
});
$body.append($para);
});
// Buttons
let $footer = $modal.find(".modal-footer").empty();
settings.buttons.forEach(button => {
let $btn = $("<button>", {
type: "button",
class: "btn rounded-1 " + button.className,
tabIndex: button.tabIndex,
html: button.iconClass ?`<i class="${button.iconClass} ${button.text ? "me-2" : ""}"></i>${button.text ? button.text : ""}` : button.text
});
$btn.on("click", async () => {
if (button.onClick) await button.onClick();
if (button.closeModal) $modal.modal("hide");
});
$footer.append($btn);
});
$modal.modal("show");
}

View File

@ -0,0 +1,390 @@
// region Loaded Servers
var _loadedServers = []
var selectedServer = null;
function getLoadedServer(options) {
let servers = _loadedServers.filter(item => {
for (let key in options) {
if (item[key] !== options[key]) {
return false
}
}
return true;
});
return servers || [];
}
function getServerFromSnowflake(id) {
server = getLoadedServer({id: id});
if (!server.length) {
throw new Error("No Server with that ID");
}
return server[0];
}
function addToLoadedServers(serverData, autoSelect=false) {
_loadedServers.push(serverData);
createSelectButton(serverData);
if (autoSelect) {
selectServer(serverData["id"]);
}
}
function removeFromLoadedServers(id) {
_loadedServers = _loadedServers.filter(item => item.id !== id);
removeSelectButton(id)
if (selectedServer.id === id) {
selectedServer(null);
}
}
// region Loaded Channels
var _loadedChannels = {};
async function loadedChannels(serverId) {
if (!(serverId in _loadedChannels)) {
await fetchChannels(serverId);
}
return _loadedChannels[serverId]
}
$(document).on("selectedServerChange", async function() {
// Try load channels to determine if bot has permissions
loadedChannels(selectedServer.id);
});
const fetchChannels = async serverId => {
$(".sidebar .sidebar-item").prop("disabled", true);
try {
channels = await ajaxRequest(`/generate-channels?guild=${serverId}`, "GET");
_loadedChannels[serverId] = channels;
unspotItem(serverId); // remove the spot because no errors occured
}
catch (error) {
logError(error);
unspotItem(serverId);
switch (error?.status) {
case 429:
rateLimitedLoadingChannels(error.responseJSON.retry_after);
break;
case 403:
notAuthorisedLoadingChannels(serverId);
break;
default:
alert("unknown error loading channels");
break;
}
}
finally {
$(".sidebar .sidebar-item").prop("disabled", false);
}
}
const rateLimitedLoadingChannels = retryAfterSeconds => {
createModal({
title: "Failed to Fetch Server Channels",
texts: [
{ content: "Discord is rate-limiting your request." },
{ content: `This happens when making requests too quickly. Retry after ${retryAfterSeconds} seconds to continue without issue.` }
],
buttons: [
{
className: "btn-warning px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
}
const notAuthorisedLoadingChannels = serverId => {
// Mark the sidebar item as non-operational
spotItem(serverId, "danger");
const inviteBotToServer = () => {
window.open(
`https://discord.com/oauth2/authorize
?client_id=${discordClientId}
&permissions=2147534848
&scope=bot+applications.commands
&guild_id=${serverId}
&disable_guild_select=true`,
"_blank"
);
}
// Inform the user auth problem
createModal({
title: "Failed to Fetch Server Channels",
texts: [
{ content: "The Discord Bot is unable to access this server's channels, certain features will not work." },
{ content: "Ensure the Bot is a member, and has the neccessary permissions to operate." }
],
buttons: [
{
text: "Invite the Bot",
className: "btn-primary me-3",
iconClass: "bi-envelope-plus",
closeModal: true,
onClick: inviteBotToServer
},
{
className: "btn-secondary",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
}
// region UI Buttons
function createSelectButton(serverData) {
// server details
const id = serverData["id"];
const name = serverData["name"];
const iconHash = serverData["icon_hash"];
const isBotOperational = serverData["is_bot_operational"];
let template = $($("#serverItemTemplate").html());
const imageUrl = `https://cdn.discordapp.com/icons/${id}/${iconHash}.webp?size=80`;
const altText = name.split(' ').map(word => word.charAt(0)).join(''); // initials of server name, used if iconUrl is 404
template.find(".js-image").attr("src", imageUrl).attr("alt", altText);
template.find(".js-name").text(name);
template.find(".js-id").text(id);
template.find(".sidebar-item").attr("data-id", id);
// Show inoperational status, also, `isBotOperatioanl` can be null,
// so we can't rely on it's truthy value.
if (isBotOperational === false) {
template.find(".sidebar-item").addClass("spot spot-danger");
}
// Bind the button for selecting this server
template.find(".sidebar-item").off("click").on("click", function() {
const myID = $(this).data("id");
// only select if not already selected, otherwise hide sidebar on smaller screens (responsive)
selectedServer?.id !== myID ? selectServer(myID) : setSidebarVisibility(false);
});
$("#serverList").prepend(template);
}
function removeSelectButton(id) {
$(`#serverList .server-item[data-id=${id}]`).remove();
}
$("#backToSelectServer").on("click", function() {
$("#noSelectedServer").show();
$("#selectedServerContainer").hide();
$("#serverList .server-item > .server-item-selector.active").removeClass("active");
selectedServer = null;
});
// #region Server Selection
function selectServer(id) {
let server = getServerFromSnowflake(id);
if (!server) {
$("#noSelectedServer").show();
$("#selectedServerContainer").hide();
selectServer = null;
return;
}
// Change appearance of selected vs none-selected items
$("#serverList .sidebar-item").removeClass("active");
$(`#serverList .sidebar-item[data-id="${id}"]`).addClass("active");
// Global variable
selectedServer = server;
// Update UI
$("#noSelectedServer").hide();
$("#selectedServerContainer").show().css("display", "flex");
// Close sidebar on smaller screens
setSidebarVisibility(false);
// Announce change to any listeners
$(document).trigger("selectedServerChange");
}
// #region Resolve Strings
function resolveServerStrings() {
// Server icon
$(".resolve-to-server-icon").attr(
"src",
`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon_hash}.webp?size=80`
).attr("alt", selectedServer.name.split(' ').map(word => word.charAt(0)).join(''));
// Server names
$(".resolve-to-server-name").text(selectedServer.name);
// Server Guild Ids
$(".resolve-to-server-id").text(selectedServer.id);
// Bot Invite links
$(".resolve-to-invite-link").attr("href", `https://discord.com/oauth2/authorize
?client_id=${discordClientId}
&permissions=2147534848
&scope=bot+applications.commands
&guild_id=${selectedServer.id}
&disable_guild_select=true`);
}
// region Change Listener
$(document).on("selectedServerChange", async function() {
resolveServerStrings();
$("#serverJoinAlert").hide();
});
// region Load Servers
async function loadServers() {
// Remove any previously loaded servers
$(".sidebar .sidebar-item").closest("li").remove();
// Show placeholder items & hide rate limit warning
$(".sidebar .sidebar-loading").show();
$(".sidebar .server-rate-limit").hide();
try {
const servers = await ajaxRequest("/generate-servers/", "GET");
servers.forEach(server => addToLoadedServers(server, false));
}
catch (error) {
switch (error?.status) {
case 401:
window.location.href = "/login"; // discord token has expired
break;
case 429:
$(".sidebar .server-rate-limit").show();
break;
default:
logError(error);
break;
}
}
finally {
$(".sidebar .sidebar-loading").hide();
}
}
// Retry load servers button
$(".sidebar .sidebar-retry-btn").on("click", loadServers);
// region Spot Icons
const unspotItem = id => {
$(`.sidebar .sidebar-item[data-id="${id}"]`).removeClass(
"spot spot-primary spot-secondary spot-success spot-info spot-warning spot-danger"
);
}
const spotItem = (id, spotStyle) => {
$(`.sidebar .sidebar-item[data-id="${id}"]`).addClass(`spot spot-${spotStyle}`);
}
// region View Other Users
$(".js-serverUsersBtn").on("click", () => {
createModal({
title: "Other Users",
texts: [
{content: "This feature has not yet been implemented."}
],
buttons: [
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
});
// region View Edit History
$(".js-serverHistoryBtn").on("click", () => {
createModal({
title: "Edit History",
texts: [
{content: "This feature has not yet been implemented."}
],
buttons: [
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
});
// region Delete Server Data
$(".js-eraseServerBtn").on("click", () => {
const server = selectedServer; // Store incase it changes
const itemsToLose = arrayToHtmlList([
"Subscriptions",
"Filters",
"Message Styles",
"Tracked Content"
]).addClass("mb-3").prop("outerHTML");
const eraseServerData = () => {
alert("not implemented")
}
createModal({
title: `Delete Data for ${server.name}?`,
texts: [
{content: "You will lose all data related to this server, including:"},
{content: itemsToLose, html: true},
{content: "Please reconsider this decision."}
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: eraseServerData
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
})
});

View File

@ -0,0 +1,620 @@
// region Init Table
function initializeDataTable(tableId, columns) {
$(tableId).DataTable({
info: false,
paging: false,
ordering: false,
searching: false,
autoWidth: false,
order: [],
language: {
emptyTable: "No results found"
},
select: {
style: "multi+shift",
selector: 'th:first-child input[type="checkbox"]'
},
columnDefs: [
{ orderable: false, targets: "no-sort" },
{
targets: 0,
checkboxes: { selectRow: true }
},
],
columns: [
{
// Select row checkbox column
title: '<input type="checkbox" class="form-check-input table-select-all" />',
data: null,
orderable: false,
className: "col-checkbox text-center",
render: function() {
return '<input type="checkbox" class="form-check-input table-select-row" />'
}
},
{ data: "id", visible: false },
...columns
]
});
bindTablePagination(tableId);
bindTablePageSizer(tableId);
bindTableSearch(tableId);
bindRefreshButton(tableId);
bindTableSelectColumn(tableId);
}
// region Filters & Ordering
// Filter methods
var _tableFilters = {};
function getTableFilters(tableId) {
return _tableFilters[tableId];
}
function setTableFilter(tableId, key, value) {
if (!_tableFilters[tableId]) {
_tableFilters[tableId] = {};
}
_tableFilters[tableId][key] = value;
}
// Sort methods
var _tableOrdering = {};
function getTableOrdering(tableId) {
return _tableOrdering[tableId];
}
function setTableOrdering(tableId, value) {
_tableOrdering[tableId] = value;
}
// Clear all kinds of sorting and filtering when changing servers
$(document).on("selectedServerChange", function() {
_tableFilters = {};
_tableOrdering = {};
$(".table-search-input").val("");
});
// region Load & Clear Data
function wipeTable(tableId) {
$(`${tableId} thead .table-select-all`).prop("checked", false).prop("indeterminate", false);
$(tableId).DataTable().clear().draw(false)
}
function populateTable(tableId, data) {
$(tableId).DataTable().rows.add(data.results).draw(false);
updateTablePagination(tableId, data);
updateTableTotalCount(tableId, data);
}
async function loadTableData(tableId, url, method) {
fixTablePagination(tableId);
disableTableControls(tableId);
// Create querystring for filtering against the API
const filters = getTableFilters(tableId);
const ordering = getTableOrdering(tableId);
const querystring = makeQuerystring(filters, ordering);
// API request
const data = await ajaxRequest(url + querystring, method);
// Update table with new data
wipeTable(tableId);
populateTable(tableId, data);
enableTableControls(tableId);
}
// region Pagination
function bindTablePagination(tableId) {
let $paginationArea = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find('.pagination');
$paginationArea.on("click", ".page-link", function() {
let currentPage = parseInt($paginationArea.data("page"));
let wantedPage;
if ($(this).hasClass("page-prev")) {
wantedPage = currentPage - 1;
}
else if ($(this).hasClass("page-next")) {
wantedPage = currentPage + 1;
}
else {
wantedPage = $(this).attr("data-page")
}
setTableFilter(tableId, "page", wantedPage);
$(tableId).trigger("doDataLoad");
});
}
function fixTablePagination(tableId) {
let filters = getTableFilters(tableId);
let $pageSizer = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find(".table-page-sizer");
if (!("page" in filters)) {
setTableFilter(tableId, "page", 1);
}
if (!("page_size" in filters)) {
setTableFilter(tableId, "page_size", $($pageSizer).val())
}
}
function updateTablePagination(tableId, data) {
let filters = getTableFilters(tableId);
let $paginationArea = $(tableId).closest('.js-tableBody').siblings('.js-tableControls').find('.pagination');
$paginationArea.data("page", filters.page); // store the page for later
// Remove existing buttons for specific pages
$paginationArea.find(".page-pick").remove();
// Enable/disable 'next' and 'prev' buttons
$paginationArea.find(".page-prev").toggleClass("disabled", !data.previous);
$paginationArea.find(".page-next").toggleClass("disabled", !data.next);
const pagesToShow = Math.max(Math.ceil(data.count / filters.page_size), 1);
const maxPagesToShow = 10;
let startPage = 1;
let endPage;
let page = parseInt(filters.page);
// Determine the start and end page
if (pagesToShow <= maxPagesToShow) {
endPage = pagesToShow;
}
else {
const halfVisible = Math.floor(maxPagesToShow / 2);
if (page <= halfVisible) {
endPage = maxPagesToShow;
}
else if (page + halfVisible >= pagesToShow) {
startPage = pagesToShow - maxPagesToShow + 1;
endPage = pagesToShow;
}
else {
startPage = page - halfVisible;
endPage = page + halfVisible;
}
}
// Add buttons
if (startPage > 1) {
AddTablePageButton($paginationArea, 1);
if (startPage > 2) {
AddTablePageEllipsis($paginationArea);
}
}
for (i = startPage; i <= endPage; i++) {
AddTablePageButton($paginationArea, i, page);
}
if (endPage < pagesToShow) {
if (endPage < pagesToShow - 1) {
AddTablePageEllipsis($paginationArea);
}
AddTablePageButton($paginationArea, pagesToShow);
}
}
function AddTablePageButton($paginationArea, number, currentPage) {
let pageItem = $("<li>").addClass("page-item");
let pageLink = $("<button>")
.attr("type", "button")
.attr("data-page", number)
.addClass("page-link page-pick")
.text(number);
if (number === parseInt(currentPage)) {
pageLink.addClass("disabled").attr("tabindex", -1);
}
pageItem.append(pageLink);
$paginationArea.find(".page-next").parent().before(pageItem);
}
function AddTablePageEllipsis($paginationArea) {
let ellipsisItem = $("<li>").addClass("page-item disabled");
let ellipsisLink = $("<span>").addClass("page-link page-pick").text("...");
ellipsisItem.append(ellipsisLink);
$paginationArea.find(".page-next").parent().before(ellipsisItem);
}
// region Page Sizer
function updateTableTotalCount(tableId, data) {
let $tableControls = $(tableId).closest('.js-tableBody').siblings('.js-tableControls');
$tableControls.find('.pageinfo-total').text(data.count);
}
function bindTablePageSizer(tableId) {
let $tableControls = $(tableId).closest('.js-tableBody').siblings('.js-tableControls');
$tableControls.on("change", ".table-page-sizer", function() {
setTableFilter(tableId, "page", "1");
setTableFilter(tableId, "page_size", $(this).val());
$(tableId).trigger("doDataLoad");
});
}
// region Search Filters
_searchTimeouts = {};
function bindTableSearch(tableId) {
const $tableFilters = $(tableId).closest('.js-tableBody').siblings('.js-tableFilters');
$tableFilters.on("input", ".table-search-input", function() {
$(this).data("was-focused", true);
clearTimeout(_searchTimeouts[tableId]);
const searchString = $(this).val();
setTableFilter(tableId, "search", searchString);
setTableFilter(tableId, "page", 1); // back to first page, as desired page no. might not exist after filtering
_searchTimeouts[tableId] = setTimeout(function() {
$(tableId).trigger("doDataLoad");
}, 300);
});
}
// region Button Controls
function bindRefreshButton(tableId) {
const $tableFilters = $(tableId).closest('.js-tableBody').siblings('.js-tableFilters');
$tableFilters.on("click", ".table-refresh-btn", function() {
$tableFilters.find(".table-del-btn").prop("disabled", true);
$(tableId).trigger("doDataLoad");
})
}
// region Select Checkboxes
function bindTableSelectColumn(tableId) {
$(tableId).on("change", "tbody tr .table-select-row", function() {
let selected = $(this).prop("checked");
let rowIndex = $(this).closest("tr").index();
let row = $(tableId).DataTable().row(rowIndex);
selected === true ? row.select() : row.deselect();
determineSelectAllState(tableId);
});
$(tableId).on("change", "thead .table-select-all", function() {
let selected = $(this).prop("checked");
let table = $(tableId).DataTable();
$(tableId).find("tbody tr").each(function(rowIndex) {
let row = table.row(rowIndex);
selected === true ? row.select() : row.deselect();
$(this).find(".table-select-row").prop("checked", selected);
});
determineSelectAllState(tableId);
});
}
function determineSelectAllState(tableId) {
let table = $(tableId).DataTable();
let selectedRowsCount = table.rows(".selected").data().toArray().length;
let allRowsCount = table.rows().data().toArray().length;
let doCheck = selectedRowsCount === allRowsCount;
let doIndeterminate = !doCheck && selectedRowsCount > 0;
$checkbox = $(tableId).find("thead .table-select-all");
$checkbox.prop("checked", doCheck);
$checkbox.prop("indeterminate", doIndeterminate);
$(tableId).closest(".js-tableBody").siblings(".js-tableFilters").find(".table-del-btn").prop("disabled", !doCheck && !doIndeterminate);
}
// region On/Off Controls
function enableTableControls(tableId) {
setTableControlsUsability(tableId, false);
}
function disableTableControls(tableId) {
setTableControlsUsability(tableId, true);
}
function setTableControlsUsability(tableId, disabled) {
const $table = $(tableId);
const $tableBody = $table.closest(".js-tableBody");
const $tableFilters = $tableBody.siblings(".js-tableFilters");
const $tableControls = $tableBody.siblings(".tableControls");
$tableBody.find(".disable-while-loading").prop("disabled", disabled);
$tableFilters.find(".disable-while-loading").prop("disabled", disabled);
$tableControls.find(".disable-while-loading").prop("disabled", disabled);
// Re-focus search bar if used
const $search = $tableFilters.find(".disable-while-loading[type=search]");
$search.data("was-focused") ? $search.focus() : null;
}
// region Modals
async function openDataModal(modalId, pk, url) {
$modal = $(modalId);
$modal.data("primary-key", pk);
clearValidation($modal);
if (parseInt(pk) === -1) {
$modal.find(".form-create").show();
$modal.find(".form-edit").hide();
setDefaultModalData($modal);
}
else {
$modal.find(".form-create").hide();
$modal.find(".form-edit").show();
await loadModalData($modal, url);
}
$modal.modal("show");
}
function setDefaultModalData($modal) {
$modal.find("[data-field]").each(function() {
const type = $(this).attr("type");
const defaultVal = $(this).attr("data-default") || "";
if (type === "checkbox") {
$(this).prop("checked", defaultVal === "true");
}
else if (type === "datetime-local") {
$(this).val(getCurrentDateTime());
}
else if ($(this).is("select") && defaultVal === "firstOption") {
$(this).val($(this).find("option:first").val()).change();
}
else {
$(this).val(defaultVal).change();
}
});
}
function clearValidation($modal) {
$modal.find(".invalid-feedback").remove();
$modal.find(".is-invalid").removeClass("is-invalid");
}
async function loadModalData($modal, url) {
const data = await ajaxRequest(url, "GET");
$modal.find("[data-field]").each(function() {
const key = $(this).attr("data-field");
const value = data[key];
if (typeof value === "boolean") {
$(this).prop("checked", value);
}
else if (isISODateTimeString(value)) {
$(this).val(value.split('+')[0].substring(0, 16));
}
else if ($(this).attr("type") === "color") {
$(this).val(`#${value}`);
}
else {
$(this).val(value).change();
}
});
}
async function onModalSubmit($modal, $table, url) {
if (!selectedServer) {
return;
}
clearValidation($modal);
let data = { server: selectedServer.id };
$modal.find("[data-field]").each(function() {
const type = $(this).attr("type");
const key = $(this).attr("data-field");
if (!key) {
return;
}
let value;
switch (type) {
case "checkbox":
value = $(this).prop("checked");
break;
case "color":
value = $(this).val();
value = value ? value.replace("#", "") : value;
break;
default:
value = $(this).val();
break;
}
data[key] = value;
});
const formData = objectToFormData(data);
const id = $modal.data("primary-key");
const isNewItem = parseInt(id) !== -1;
const method = isNewItem ? "PUT" : "POST";
url = isNewItem ? url + `${id}/` : url;
ajaxRequest(url, method, formData)
.then(response => {
$table.trigger("doDataLoad");
$modal.modal("hide");
})
.catch(error => {
logError(error);
if (typeof error === "object" && "responseJSON" in error) {
renderErrorMessages($modal, error.responseJSON);
}
});
}
// region Modal Error Msgs
function renderErrorMessages($modal, errorObj) {
for (const key in errorObj) {
const value = errorObj[key];
const $input = $modal.find(`[data-field="${key}"]`);
$input.addClass("is-invalid");
$input.nextAll(".form-text").last().after(
`<div class="invalid-feedback">${value}</div>`
)
}
}
// region Table Col Types
function renderEditColumn(data) {
const name = sanitise(data);
return `<span class="act-as-link edit-modal" role="button">${name}</span>`
}
function renderAnchorColumn(name, href) {
name = sanitise(name);
href = sanitise(href);
return `<a href="${href}" class="act-as-link">${name}</a>`;
}
function renderBooleanColumn(data) {
const iconClass = data ? "bi-check-circle-fill text-success" : "bi-x-circle-fill text-danger";
return `<i class="bi ${iconClass}"></i>`;
}
function renderBadgeColumn(data, colour=null) {
let badge = $(`<span class="badge text-bg-secondary rounded-1 border border-1">${data}</span>`)
if (colour) {
badge[0].style.setProperty("border-color", `#${colour}`, "important");
}
return badge.prop("outerHTML");
}
function renderArrayBadgesColumn(data) {
let badges = $("<div>");
data.forEach((item, index) => {
let badge = $(`<span class="badge text-bg-secondary rounded-1">${item}</span>`);
if (index > 0) { badge.addClass("ms-2") }
badges.append(badge);
});
return badges.html();
}
function renderArrayDropdownColumn(data, icon, headerText) {
if (!data.length) {
return "";
}
let $dropdown = $(`
<div class="dropdown">
<button type="button" class="dropdown-toggle" data-bs-toggle="dropdown">
<i class="fs-5 bi ${icon}"></i>
</button>
<div class="dropdown-menu">
<li><h6 class="dropdown-header">${headerText} (${data.length})</h6></li>
</div>
</div>
`);
let $dropdownItems = $dropdown.find(".dropdown-menu")
data.forEach(item => {
let $itemBtn = $(`<li><button type="button" class="dropdown-item">${item}</button></li>`);
$dropdownItems.append($itemBtn);
});
return $dropdown.prop("outerHTML");
}
function renderHexColourColumn(data, type, row) {
if (!row.is_embed) {
return "";
}
const hexWithHashtag = `#${data}`.toUpperCase();
let icon = $("<div>");
icon.addClass("col-hex-icon");
icon.css("background-color", hexWithHashtag);
return $(`<span data-bs-toggle="tooltip" data-bs-title="${hexWithHashtag}">${icon.prop("outerHTML")}</span>`).tooltip()[0];
}
function renderMutatorColumn(data) {
if (!("id" in data)) {
return "";
}
return $(`<span class="badge text-bg-secondary rounded-1">${data.name}</span>`).prop("outerHTML");
}
const renderLinkToStyleColumn = style => {
if (!style.is_embed) {
return "";
}
const hexWithHashtag = `#${style.colour}`.toUpperCase();
let icon = $("<div>");
icon.addClass("col-hex-icon js-openSubStyle");
icon.css("background-color", hexWithHashtag);
return $(`<span data-bs-toggle="tooltip" data-bs-title="${hexWithHashtag}" role="button">${icon.prop("outerHTML")}</span>`).tooltip()[0];
}
const renderPopoverBadgesColumn = (items, iconClass) => {
if (!items.length) {
return "";
}
let $span = $("<span>");
$span.attr("data-bs-toggle", "popover");
$span.attr("data-bs-trigger", "hover focus");
$span.attr("data-bs-custom-class", "table-badge-popover")
$span.attr("data-bs-html", "true");
let $placeholderContainer = $("<div>");
items.forEach(item => {
let $badge = $("<div>");
$badge.addClass("badge text-bg-secondary rounded-1 me-2 mb-2 text-wrap mw-100 text-start");
$badge.text(item);
$placeholderContainer.append($badge);
});
$span.attr("data-bs-content", $placeholderContainer.html());
$span.html(`<i class="bi ${iconClass} fs-5"></i>`)
return $span.popover()[0];
}
// region Get Table Parts
function getTableFiltersComponent(tableId) {
return $(tableId).closest(".js-tableBody").siblings(".js-tableFilters");
}
function getTableControlsComponent(tableId) {
return $(tableId).closest(".js-tableBody").siblings(".js-tableControls");
}
function getSelectedTableRows(tableId) {
return $(tableId).DataTable().rows(".selected").data().toArray();
}

View File

@ -0,0 +1,56 @@
const contentTableId = "#contentTable";
// region Init Module
function initContentModule() {
initializeDataTable(
contentTableId,
[
{
title: "Subscription",
data: "subscription"
},
{
title: "Item ID",
data: "item_id"
},
{
title: "Item GUID",
data: "item_guid"
},
{
title: "Title",
data: "item_title"
},
{
title: "URL",
data: "item_url"
},
{
title: "Content Hash",
data: "item_content_hash"
}
]
);
}
// region Load Data
$(document).on("selectedServerChange", async function() {
await loadContentData();
});
$(contentTableId).on("doDataLoad", async function() {
await loadContentData();
})
async function loadContentData() {
if (!selectedServer){
return;
}
setTableFilter(contentTableId, "subscription__server", selectedServer.id);
await loadTableData(contentTableId, "/api/content/", "GET");
}

View File

@ -0,0 +1,204 @@
const filterTableId = "#filterTable";
const filterModalId = "#filterFormModal";
// region Init Module
function initFiltersModule() {
initializeDataTable(
filterTableId,
[
{
title: "Name",
data: "name",
render: renderEditColumn
},
{
title: "Match",
data: "match"
},
{
title: "Algorithm",
data: "matching_algorithm",
render: function(data) {
switch (data) {
case 1: return "Any Word";
case 2: return "All Words";
case 3: return "Exact Match";
case 4: return "Regular Expression";
case 5: return "Fuzzy Match";
default:
console.error(`unknown matching algorithm '${data}'`);
return data;
}
}
},
{
title: "Case-Sensitive",
data: "is_insensitive",
className: "text-center",
render: data => renderBooleanColumn(!data)
},
{
title: "Type",
data: "is_whitelist",
render: data => data ? "Only Allow" : "Reject All"
}
]
)
}
// region Load Data
$(document).on("selectedServerChange", async function() {
await loadFilterData();
});
$(filterTableId).on("doDataLoad", async function() {
await loadFilterData();
});
async function loadFilterData() {
if (!selectedServer) {
return;
}
setTableFilter(filterTableId, "server", selectedServer.id);
await loadTableData(filterTableId, "/api/filters/", "GET");
}
// Table Delete Btns
$(filterModalId).find(".modal-del-btn").on("click", async function() {
$(filterModalId).modal("hide");
const id = parseInt($(filterModalId).data("primary-key"));
const filter = $(filterTableId).DataTable().row((idx, row) => { return row.id === id }).data();
const name = sanitise(filter.name);
const deleteFilter = async () => {
await ajaxRequest(`/api/filters/${filter.id}/`, "DELETE");
setTimeout(async () => {
$(filterTableId).trigger("doDataLoad");
await loadSubModalOptions(
$(subModalId).find('[data-field="filters"]'),
`/api/filters/?server=${selectedServer.id}`
);
}, 600);
}
createModal({
title: "Delete a Content Filter",
texts: [
{
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
html: true
},
{ content: "This action is irreversible, you will lose this filter forever." }
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteFilter,
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true,
onClick: async () => $(filterModalId).modal("show")
}
]
});
});
getTableFiltersComponent(filterTableId).find(".table-del-btn").on("click", async function() {
const rows = getSelectedTableRows(filterTableId);
const isMany = rows.length > 1;
const names = rows.map(row => row.name);
const deleteFilters = async () => {
rows.forEach(async row => {
await ajaxRequest(`/api/filters/${row.id}/`, "DELETE");
});
setTimeout(async () => {
$(filterTableId).trigger("doDataLoad");
await loadSubModalOptions(
$(subModalId).find('[data-field="filters"]'),
`/api/filters/?server=${selectedServer.id}`
);
}, 600);
}
createModal({
title: `Delete ${isMany ? "Many Content Filters" : "Content Filter"}`,
texts: [
{
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} content filter${isMany ? "s" : ""}?</p>`,
html: true
},
{
content: arrayToHtmlList(names, true).prop("outerHTML"),
html: true
}
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteFilters
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
});
// region New/Edit Modal
$(filterTableId).closest('.js-tableBody').siblings('.js-tableFilters').on("click", ".table-new-btn", async function() {
await openDataModal(filterModalId, -1);
});
$(filterTableId).on("click", ".edit-modal", async function() {
const id = $(filterTableId).DataTable().row($(this).closest("tr")).data().id;
await openDataModal(filterModalId, id, `/api/filters/${id}/`);
});
$(filterModalId).on("submit", async function(event) {
event.preventDefault();
await onModalSubmit(
$(filterModalId),
$(filterTableId),
"/api/filters/"
);
});
// region Load Modal Options
$(document).ready(async function() {
await loadMatchingAlgorithms();
});
async function loadMatchingAlgorithms() {
const data = await ajaxRequest("/api/filters/", "OPTIONS");
data.actions.GET.matching_algorithm.choices.forEach(algorithm => {
$(filterModalId).find('[data-field="matching_algorithm"]').append($(
"<option>",
{
text: algorithm.display_name,
value: algorithm.value > 0 ? algorithm.value : ""
}
));
});
}

View File

@ -0,0 +1,293 @@
const styleTableId = "#styleTable";
const styleModalId = "#styleFormModal";
// region Init Module
function initMessageStylesModule() {
initializeDataTable(
styleTableId,
[
{
title: "Name",
data: "name",
className: "col-name",
render: (name, type, style) => {
const elem = renderEditColumn(name);
return style.auto_created ?
$(elem).removeClass("edit-modal").addClass("disabled").attr("role", null)[0]
: elem;
}
},
{
title: "Embed",
data: "is_embed",
className: "col-icon",
render: renderBooleanColumn
},
{
title: "Colour",
data: "colour",
className: "col-hex",
render: renderHexColourColumn
},
{
title: "Hyperlinked",
data: "is_hyperlinked",
className: "col-icon-wide",
render: renderBooleanColumn
},
{
title: "Authored",
data: "show_author",
className: "col-icon-wide",
render: renderBooleanColumn
},
{
title: "Timestamped",
data: "show_timestamp",
className: "col-icon-wide",
render: renderBooleanColumn
},
{
title: "Images",
data: "show_images",
className: "col-icon",
render: renderBooleanColumn
},
{
title: "Fetch Images",
data: "fetch_images",
className: "col-icon-wide",
render: renderBooleanColumn
},
{
title: "Title Mutator",
data: "title_mutator_detail",
render: (data, type, row) => renderMutatorColumn(row.title_mutator_detail)
},
{
title: "Description Mutator",
data: "description_mutator_detail",
render: (data, type, row) => renderMutatorColumn(row.description_mutator_detail)
},
{
title: "Editable",
data: "auto_created",
className: "text-center",
render: function(data) {
const icon = renderBooleanColumn(!data);
if (!data) {
return icon;
}
return $(`
<span data-bs-trigger="hover focus"
data-bs-custom-class="text-center"
data-bs-toggle="popover"
data-bs-content="This style was created internally, and cannot be altered.">
${icon}
</span>
`).popover()[0];
}
}
]
);
};
// region Load Data
$(document).on("selectedServerChange", async function() {
await loadMessageStyleData();
});
$(document).on("doDataLoad", async function() {
await loadMessageStyleData();
});
async function loadMessageStyleData() {
if (!selectedServer) {
return;
}
setTableFilter(styleTableId, "server", selectedServer.id);
await loadTableData(styleTableId, "/api/message-styles/", "GET");
}
// region Table Delete Btns
$(styleModalId).find(".modal-del-btn").on("click", async function() {
$(styleModalId).modal("hide");
const id = parseInt($(styleModalId).data("primary-key"));
const style = $(styleTableId).DataTable().row((idx, row) => { return row.id === id }).data();
const name = sanitise(style.name);
const deleteStyle = async () => {
await ajaxRequest(`/api/message-styles/${style.id}/`, "DELETE");
setTimeout(async () => {
$(styleTableId).trigger("doDataLoad");
await loadSubModalOptions(
$(subModalId).find('[data-field="message_style"]'),
`/api/message-styles/?server=${selectedServer.id}`
);
}, 600);
}
createModal({
title: "Delete a Message Style",
texts: [
{
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
html: true
},
{ content: "This action is irreversible, you will lose this filter forever." }
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteStyle,
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true,
onClick: async () => $(styleModalId).modal("show")
}
]
});
});
getTableFiltersComponent(styleTableId).find(".table-del-btn").on("click", async function() {
const rows = getSelectedTableRows(styleTableId);
const isMany = rows.length > 1;
for (const row of rows) {
if (!row.auto_created) {
continue;
}
createModal({
title: "Cannot Delete Style",
texts: [
{
content: `<p><b>${sanitise(row.name)}</b> can't be deleted, as it was created by the system.</p>`,
html: true
},
{ content: "System-owned styles cannot be modified or deleted." },
],
buttons: [
{
className: "btn-warning px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
return
}
const names = rows.map(row => row.name);
const deleteStyles = async () => {
rows.forEach(async row => {
await ajaxRequest(`/api/message-styles/${row.id}/`, "DELETE");
});
setTimeout(async () => {
$(styleTableId).trigger("doDataLoad");
await loadSubModalOptions(
$(subModalId).find('[data-field="message_style"]'),
`/api/message-styles/?server=${selectedServer.id}`
);
}, 600);
}
createModal({
title: `Delete ${isMany ? "Many Message Styles" : "Message Style"}`,
texts: [
{
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} message style${isMany ? "s" : ""}?</p>`,
html: true
},
{
content: arrayToHtmlList(names, true).prop("outerHTML"),
html: true
}
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteStyles
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
});
// region New/Edit Modal
$(styleTableId).closest(".js-tableBody").siblings(".js-tableFilters").on("click", ".table-new-btn", async function() {
await openDataModal(styleModalId, -1);
});
$(styleTableId).on("click", ".edit-modal", async function() {
let id = $(styleTableId).DataTable().row($(this).closest("tr")).data().id;
await openDataModal(styleModalId, id, `/api/message-styles/${id}/`);
});
$(styleModalId).on("submit", async function(event) {
event.preventDefault();
onModalSubmit(
$(styleModalId),
$(styleTableId),
"/api/message-styles/"
).then(async () => {
// Reload sub data to reflect style changes
$(subTableId).trigger("doDataLoad");
await loadSubModalOptions(
$(subModalId).find('[data-field="message_style"]'),
`/api/message-styles/?server=${selectedServer.id}`
);
});
});
// region Load Mutator Options
$(document).ready(async function() {
await loadMutatorOptions();
});
async function loadMutatorOptions() {
let $inputs = $(styleModalId).find('[data-field="title_mutator"], [data-field="description_mutator"]');
$inputs.val("").change();
$inputs.prop("disabled", true);
$inputs.find("option").each(function() {
if ($(this).val()) {
$(this).remove();
}
});
const data = await ajaxRequest("/api/message-mutators/?page_size=25", "GET");
data.results.forEach(mutator => {
$inputs.append($(
"<option>",
{text: mutator.name, value: mutator.id}
));
});
$inputs.prop("disabled", false);
}

View File

@ -0,0 +1,304 @@
const subTableId = "#subTable";
const subModalId = "#subFormModal";
// region Init Module
function initSubscriptionsModule() {
initializeDataTable(
subTableId,
[
{
title: "Name",
data: "name",
className: "col-name",
render: renderEditColumn
},
{
title: "URL",
data: "url",
className: "col-url",
render: url => renderAnchorColumn(url, url)
},
{
title: "Channels",
data: "channels_detail",
className: "col-icon",
render: data => renderPopoverBadgesColumn(data.map(item => `#${item.name}`), "bi-hash")
},
{
title: "Filters",
data: "filters_detail",
className: "col-icon",
render: data => renderPopoverBadgesColumn(data.map(item => item.name), "bi-funnel")
},
{
title: "Rules",
data: "unique_rules_detail",
className: "col-icon",
render: data => renderPopoverBadgesColumn(data.map(item => item.name), "bi-vr")
},
{
title: "Style",
data: "message_style_detail",
className: "col-hex",
render: renderLinkToStyleColumn
},
{
title: "Created At",
data: "created_at",
className: "col-date",
render: function(data, type) {
let dateTime = new Date(data);
return $(`
<span data-bs-trigger="hover focus"
data-bs-html="true"
data-bs-custom-class="text-center"
data-bs-toggle="popover"
data-bs-content="${formatStringDate(dateTime, "%a, %D %B, %Y<br>%H:%M:%S")}">
${formatStringDate(dateTime, "%D, %b %Y")}
</span>
`).popover()[0];
}
},
{
title: "Enabled",
data: "active",
className: "col-switch text-center form-switch",
render: function(data, type) {
return `<input type="checkbox" class="sub-toggle-active form-check-input ms-0" ${data ? "checked" : ""} />`
}
}
]
);
}
// region Load Data
$(document).on("selectedServerChange", async function() {
await loadSubscriptionData();
});
$(subTableId).on("doDataLoad", async function() {
await loadSubscriptionData();
})
async function loadSubscriptionData() {
if (!selectedServer) {
return;
}
setTableFilter(subTableId, "server", selectedServer.id);
await loadTableData(subTableId, `/api/subscriptions/`, "GET");
}
// region Table Switches
$(subTableId).on("change", ".sub-toggle-active", async function() {
// Temporarily disable all switches to prevent spam.
$(subTableId).find(".sub-toggle-active").prop("disabled", true);
setTimeout(() => { $(subTableId).find(".sub-toggle-active").prop("disabled", false); }, 800);
let active = $(this).prop("checked");
let id = $(subTableId).DataTable().row($(this).closest("tr")).data().id;
let sub = await ajaxRequest(`/api/subscriptions/${id}/`, "GET");
sub.active = active;
let formData = objectToFormData(sub);
await ajaxRequest(`/api/subscriptions/${id}/`, "PUT", formData);
});
// region Table Delete Buttons
$(subModalId).find(".modal-del-btn").on("click", async function() {
$(subModalId).modal("hide");
const id = parseInt($(subModalId).data("primary-key"));
const subscription = $(subTableId).DataTable().row((idx, row) => { return row.id === id }).data();
const name = sanitise(subscription.name);
const deleteSubscription = async () => {
await ajaxRequest(`/api/subscriptions/${subscription.id}/`, "DELETE");
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
}
createModal({
title: "Delete a Subscriptions",
texts: [
{
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
html: true
},
{ content: "This action is irreversible, you will lose this subscription forever." }
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteSubscription,
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true,
onClick: async () => $(subModalId).modal("show")
}
]
});
})
getTableFiltersComponent(subTableId).find(".table-del-btn").on("click", async function() {
const rows = getSelectedTableRows(subTableId);
const names = rows.map(row => row.name);
const isMany = names.length > 1;
const deleteSubscriptions = () => {
rows.forEach(async row => {
await ajaxRequest(`/api/subscriptions/${row.id}/`, "DELETE");
});
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
}
createModal({
title: `Delete ${isMany ? "Many Subscriptions" : "Subscription"}`,
texts: [
{
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} subscription${isMany ? "s" : ""}?</p>`,
html: true
},
{
content: arrayToHtmlList(names, true).prop("outerHTML"),
html: true
}
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteSubscriptions
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
});
// region New/Edit Modal
$(subTableId).closest('.js-tableBody').siblings('.js-tableFilters').on("click", ".table-new-btn", async function() {
await openSubModal(-1);
});
$(subTableId).on("click", ".edit-modal", async function() {
const id = $(subTableId).DataTable().row($(this).closest("tr")).data().id;
await openSubModal(id);
})
async function openSubModal(id) {
await loadChannelOptions();
await openDataModal(subModalId, id, id !== -1 ? `/api/subscriptions/${id}/`: null);
}
$(subModalId).on("submit", async function(event) {
event.preventDefault();
await onModalSubmit(
$(subModalId),
$(subTableId),
"/api/subscriptions/"
);
});
// region Load Modal Options
$(document).on("selectedServerChange", async function() {
await loadSubModalOptions(
$(subModalId).find('[data-field="message_style"]'),
`/api/message-styles/?server=${selectedServer.id}`
);
await loadSubModalOptions(
$(subModalId).find('[data-field="filters"]'),
`/api/filters/?server=${selectedServer.id}`
);
});
$(document).ready(async function() {
await loadSubModalOptions(
$(subModalId).find('[data-field="unique_rules"]'),
"/api/unique-content-rules/"
);
})
async function loadSubModalOptions($input, url) {
// Disable and clear input
$input.val("").change();
$input.prop("disabled", true);
// Delete existing options
$input.find("option").each(function() {
if ($(this).val()) {
$(this).remove();
}
});
// Load new values
const data = await ajaxRequest(url, "GET");
data.results.forEach(item => {
$input.append($(
"<option>",
{text: item.name, value: item.id}
));
});
// Re-enable input
$input.prop("disabled", false);
}
// Channel options aren't loaded from an API, like other options.
async function loadChannelOptions() {
$input = $(subModalId).find('[data-field="channels"]');
$input.val("").change();
$input.prop("disabled", true);
$input.find("option").each(function() {
if ($(this).val()) {
$(this).remove();
}
});
const data = await loadedChannels(selectedServer.id);
data.forEach(item => {
$input.append($(
"<option>",
{text: `#${item.name}`, value: item.id}
));
})
$input.prop("disabled", false);
}
// region Open Sub Style
$(subTableId).on("click", ".js-openSubStyle", async event => {
const subTable = $(subTableId).DataTable();
const row = subTable.row($(event.currentTarget).closest("tr"));
const styleId = row.data().message_style
// Open styles tab and styles modal
$("#stylesTab").click();
await openDataModal(styleModalId, styleId, `/api/message-styles/${styleId}/`);
});

View File

@ -0,0 +1,204 @@
.render-array-dropdown-column {
position: relative;
.dropdown {
}
// .col-badge {
// position: absolute;
// top: 50%;
// left: 50%;
// transform: translate(-50%, -50%);
// background-color: var(--bs-danger);
// border-radius: 50%;
// width: 1.25rem;
// height: 1.25rem;
// display: flex;
// justify-content: center;
// align-items: center;
// flex-shrink: 0;
// >* {
// }
// }
button[data-bs-toggle="dropdown"] {
position: relative;
width: 100%;
height: 100%;
border: 0;
padding: 0;
background: none;
}
// .col-badge {
// position: absolute;
// top: 0;
// right: 100%;
// transform: translateY(-100%);
// background-color: var(--bs-danger);
// }
.dropdown-menu {
border: none;
padding: 0.25rem;
box-shadow: var(--bs-box-shadow);
border-radius: var(--bs-border-radius-sm);
background-color: var(--bs-tertiary-bg);
.dropdown-item {
border-radius: var(--bs-border-radius-sm);
&:hover, &:focus { background-color: var(--bs-body-bg); }
}
}
}
.table {
color: var(--bs-body-color) !important;
}
.table tbody tr.selected > * {
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
color: var(--bs-body-color) !important;
}
.table.dataTable > tbody > tr.selected a {
color: var(--bs-link-color) !important;
}
/* Fuck ugly <td> height fix */
td {
height: 1px;
text-wrap: nowrap;
}
td > .btn-link { padding-left: 0; }
@-moz-document url-prefix() {
tr { height: 100%; }
td { height: 100%; }
}
/* Empty Table */
.table .dt-empty {
padding: 2rem 0;
border-bottom: none !important;
}
.table tr:hover > .dt-empty {
box-shadow: none !important;
}
/* Table Search */
.table-search-group {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: stretch;
width: 100%;
max-width: 450px;
border: 1px solid var(--bs-border-color);
border-radius: 0.25rem;
background-color: var(--bs-body-bg);
overflow: hidden;
}
.table-search-label {
display: flex;
align-items: center;
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: var(--bs-body-color);
text-align: center;
white-space: nowrap;
padding-right: 0;
border: none;
background-color: inherit;
}
.table-search-input {
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: var(--bs-body-color);
appearance: none;
background-clip: padding-box;
position: relative;
flex: 1 1 auto;
width: 1%;
min-width: 0;
border: none;
border-radius: 0;
background-color: inherit
}
.table-search-input:focus {
outline: 0;
box-shadow: none;
}
/* Button Controls */
.table-search-buttons > div {
margin-left: 1rem;
}
@media (max-width: 576px) {
.table-search-buttons > div {
margin-left: 0.25rem;
}
}
.table-search-buttons > div:first-of-type {
margin-left: 0 !important;
}
/* Table Border Colour */
table.dataTable > thead > tr > th, table.dataTable > thead > tr > td {
border-bottom: 1px solid var(--bs-border-color);
}
div.dt-container.dt-empty-footer tbody > tr:last-child > * {
border-bottom: 1px solid var(--bs-border-color);
}
/* Cell Data Types */
.table-cell-hex {
margin: 0 auto;
width: 25px;
height: 25px;
border-radius: 0.25rem;
}

View File

@ -0,0 +1,37 @@
@import "bootstrap.scss";
@import "./sidebar.scss";
@import "./tables.scss";
/* widths */
.mw-10rem {
max-width: 10rem;
}
.col-switch-width {
width: 3.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
}
/* Server Tabs */
#serverTabs .nav-item .nav-link:hover:not(.active) {
background-color: var(--bs-secondary-bg);
}
#serverTabs .nav-item .nav-link:not(.active) {
color: var(--bs-text-body);
}
.act-as-link {
color: rgba(var(--bs-link-color-rgb), 1);
text-decoration: none;
&:hover { color: var(--bs-link-hover-color-rgb); }
&:disabled, &.disabled { color: var(--bs-secondary-color) }
}

View File

@ -0,0 +1,344 @@
.sidebar-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(10px);
background-color: rgba(var(--bs-secondary-rgb), 0.50);
z-index: 998;
display: none; /* Must start as hidden! */
}
.reveal-sidebar-btn {
z-index: 998;
display: none;
position: fixed;
bottom: 1rem;
right: 1rem;
@include media-breakpoint-down(lg) { display: block; }
}
.server-rate-limit { display: none; }
.sidebar {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 300px;
color: var(--bs-body-color);
background-color: var(--bs-secondary-bg-subtle);
transition: transform 0.3s ease-in-out;
z-index: 999;
// Hide the sidebar
@include media-breakpoint-down(lg) {
transform: translateX(-100%);
position: fixed;
left: 0;
top: 0;
bottom: 0;
}
// Show the sidebar on smaller screens
&.visible {
@include media-breakpoint-down(lg) { transform: translateX(0); }
@include media-breakpoint-down(sm) { width: 100vw; }
}
.sidebar-divider { margin: 1rem; }
.sidebar-header {
display: flex;
align-items: center;
padding: 1rem 1rem 0 1rem;
text-decoration: none;
color: inherit;
.sidebar-header-link {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
.sidebar-logo {
width: 45px;
margin-right: 0.5rem;
}
.sidebar-title {
font-size: 2rem;
font-weight: bold;
}
}
// Hide on larger screens, show on smaller screens
.btn-close {
display: none;
@include media-breakpoint-down(lg) { display: block; }
}
}
.sidebar-content {
padding: 0 1rem ;
margin-bottom: auto;
list-style: none;
.sidebar-placeholder {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem;
border: none;
.sidebar-placeholder-image {
flex-shrink: 0;
width: 50px;
height: 50px;
margin-right: 0.75rem;
border-radius: $border-radius-sm;
}
.sidebar-placeholder-data {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
>.placeholder { border-radius: $border-radius-sm; }
}
}
.sidebar-item {
position: relative;
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem;
border: none;
border-radius: $border-radius-sm;
background-color: inherit;
// Highlight effect
&:not(:disabled):hover,
&:not(:disabled):focus,
&.active {
background-color: var(--bs-body-bg);
&.spot {
border-radius: 0 $border-radius-sm $border-radius-sm 0;
}
}
// 'Spot' is an alert indicator, that appears as a coloured dot against a sidebar item
&.spot {
&::before {
content: "";
transition: 0.1s ease;
transform: translate(-100%, -50%);
position: absolute;
left: -1px;
top: 50%;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--bs-body-bg);
}
&:not(:disabled):hover::before,
&:not(:disabled):focus::before,
&.active::before {
transition: 0.15s ease;
width: 0.25rem;
height: 100%;
border-radius: 0.25rem 0 0 0.25rem;
}
// Spot Colours
&.spot-primary::before { background-color: var(--bs-primary); }
&.spot-secondary::before { background-color: var(--bs-secondary); }
&.spot-success::before { background-color: var(--bs-success); }
&.spot-danger::before { background-color: var(--bs-danger); }
&.spot-warning::before { background-color: var(--bs-warning); }
&.spot-info::before { background-color: var(--bs-info); }
}
.sidebar-item-image {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 50px;
height: 50px;
border-radius: $border-radius-sm;
margin-right: 0.75rem;
}
// Includes the server name and id
.sidebar-item-data {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
&>span {
text-align: start;
display: block;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.sidebar-footer {
display: flex;
padding: 0 1rem 1rem 1rem;
}
.sidebar-btn {
display: flex;
align-items: center;
width: auto;
padding: 0.5rem;
border: none;
border-radius: $border-radius-sm;
color: var(--bs-body-color);
background-color: inherit;
&:hover, &:focus, &.active, &:has(>.show) { background-color: var(--bs-body-bg); }
&.sidebar-mini-btn {
justify-content: center;
padding-left: 1rem;
padding-right: 1rem;
}
&.dropdown { padding: 0; }
>.sidebar-menu-btn {
text-align: start;
padding: 0.5rem;
border: 0;
width: 100%;
height: 100%;
color: inherit;
background-color: inherit;
border-radius: $border-radius-sm;
}
}
.sidebar-avatar {
border-radius: 50%;
margin-right: 0.5rem;
width: 32px;
height: 32px;
}
.theme-menu {
flex-direction: row;
&.show {
display: flex;
inset: auto auto 0 auto !important;
// transform: translateX(-50%) !important;
}
.theme-btn {
display: flex;
justify-content: center;
align-items: center;
font-size: 1.25rem;
border: 0;
width: 50px;
height: 50px;
color: var(--bs-body-color);
background-color: inherit;
border-radius: $border-radius-sm;
&:hover, &:focus { background-color: var(--bs-body-bg); }
&.active {
color: var(--bs-white);
background-color: var(--bs-primary);
}
}
}
.dropdown-menu {
border: none;
padding: 0.25rem;
box-shadow: var(--bs-box-shadow);
border-radius: var(--bs-border-radius-sm);
background-color: var(--bs-tertiary-bg);
.dropdown-item {
border-radius: var(--bs-border-radius-sm);
&:hover, &:focus { background-color: var(--bs-body-bg); }
}
}
}

View File

@ -0,0 +1,213 @@
// Table Search Bar
.table-search-group {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: stretch;
width: 100%;
max-width: 450px;
border: 1px solid var(--bs-border-color);
border-radius: 0.25rem;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
overflow: hidden;
.table-search-label {
display: flex;
align-items: center;
padding: 0.375rem 0.75rem;
padding-right: 0;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: inherit;
background-color: inherit;
border: none;
text-align: center;
white-space: nowrap;
}
.table-search-input {
position: relative;
flex: 1 1 auto;
padding: 0.375rem 0.75rem;
appearance: none;
width: 1%;
min-width: 0;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
border: none;
border-radius: 0;
color: inherit;
background-color: inherit;
background-clip: padding-box;
// Disable bootstrap's focus highlights
&:focus {
outline: 0;
box-shadow: none;
}
}
}
// Badge popover
.table-badge-popover .popover-body {
display: flex;
flex-wrap: wrap;
padding: 1rem 0.5rem 0.5rem 1rem;
max-width: 200px;
}
// Table Button Controls
.table-button-controls > div {
margin-left: 1rem;
&:first-of-type { margin-left: 0 !important; }
@include media-breakpoint-down(md) { margin-left: 0.25rem; }
}
// Table
.table {
td, th { vertical-align: middle; }
th { text-wrap: nowrap; }
// Top & bottom border colours
border-color: var(--bs-border-color);
tr:first-child > *,
tr:last-child > * {
border-color: inherit !important;
}
// Selected rows
tr.selected {
> * {
color: var(--bs-body-color) !important;
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
}
a {
color: var(--bs-link-color) !important;
}
}
thead { }
tbody { }
tfoot { }
// Custom column sizes
@mixin col-styles($min-width: null, $max-width: null) {
$max-width: if($max-width == null, $min-width, $max-width);
min-width: $min-width;
max-width: $max-width;
text-overflow: ellipsis;
text-wrap: nowrap;
overflow: hidden;
}
$col-check: 53px;
$col-label: 350px;
$col-url: 420px;
$col-date: 150px;
$col-switch: 100px;
$col-icon: 120px;
.col {
&-name { @include col-styles($col-label); }
&-url { @include col-styles($col-url, $col-url * 2); }
&-date { @include col-styles($col-date); }
&-checkbox { @include col-styles($col-check); }
&-switch { @include col-styles($col-switch); }
&-icon { text-align: center; @include col-styles($col-icon); }
&-icon-wide { text-align: center; @include col-styles($col-icon, $col-icon + 30px) }
&-hex {
text-align: center;
@include col-styles($col-icon);
.col-hex-icon {
margin: 0 auto;
width: 25px;
height: 25px;
border-radius: 0.25rem;
}
}
}
}
// .table {
// // color: var(--bs-body-color);
// // // Empty table
// // .dt-empty {
// // padding: 2rem 0;
// // border-bottom: none;
// // }
// // Top & bottom border colour
// &.dataTable > thead > tr > th,
// &.dataTable > thead > tr > td {
// border-bottom: 1px solid var(--bs-border-color);
// }
// .dt-container.dt-empty-footer tbody > tr:last-child > * {
// border-bottom: 1px solid var(--bs-border-color);
// }
// // tr:hover > .dt-empty {
// // box-shadow: none !important;
// // }
// // tbody {
// // tr.selected > * {
// // color: var(--bs-body-color);
// // box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9);
// // }
// // }
// }

View File

@ -0,0 +1,183 @@
{% extends 'base.html' %}
{% load static %}
{% load compress %}
{% block title %}{% endblock title %}
{% block stylesheets %}
{% compress css %}
<link type="text/x-scss" rel="stylesheet" href="{% static '/home/scss/index.scss' %}">
{% endcompress %}
<link type="text/css" rel="stylesheet" href="{% static '/home/css/tables.css' %}">
<link type="text/css" rel="stylesheet" href="{% static '/css/select2.css' %}">
{% endblock stylesheets %}
{% block content %}
<div class="px-0 h-100">
<div class="d-flex flex-nowrap h-100">
{% include "home/sidebar.html" %}
<div class="flex-grow-1 container-fluid bg-body overflow-y-auto" style="min-width: 0;">
<div id="noSelectedServer" class="h-100">
<div class="d-flex justify-content-center align-items-center flex-column h-100">
<img src="{% static '/images/pyrss_logo.webp' %}" alt="PYRSS Logo">
<h1 class="fw-bold mb-4 font-atkinson-hyperlegible">PYRSS</h1>
<div class="d-flex align-items-center flex-nowrap flex-column">
<p class="col-lg-8 text-center">Select a server from the left hand menu to get started. For more help check the <a href="https://gitea.cor.bz/corbz/PYRSS-Website/src/branch/master/README.md" class="text-decoration-none" target="_blank">README</a>.</p>
<div class="col-lg-8 text-center">
<h5>Resources</h5>
<div class="hstack gap-3 justify-content-center">
<a href="https://gitea.cor.bz/corbz/PYRSS-Website" class="text-body text-decoration-none" target="_blank"><i class="bi bi-git fs-3"></i></a>
<a href="https://en.wikipedia.org/wiki/RSS" class="text-body text-decoration-none" target="_blank"><i class="bi bi-rss-fill fs-3"></i></a>
<a href="https://discord.com/developers/docs/intro" class="text-body text-decoration-none" target="_blank"><i class="bi bi-discord fs-3"></i></a>
<a href="https://gitea.cor.bz/corbz/PYRSS-Website/src/branch/master/README.md" class="text-body text-decoration-none" target="_blank"><i class="bi bi-question-circle-fill fs-3"></i></a>
</div>
</div>
</div>
</div>
</div>
<div id="selectedServerContainer" class="row" style="display: none;">
<div id="serverJoinAlert" class="col-12 m-0">
<div class="alert alert-warning rounded-1 px-sm-3 mt-2 mt-sm-4 mx-sm-2">
<div class="row">
<div class="col-xxl-10 d-flex align-items-center">
<div class="mb-4 mb-xxl-0">
<strong>Warning:</strong>
<br class="d-xxl-none">
The Bot isn't a member of
<span class="resolve-to-server-name"></span>,
features here will not function properly, please add the bot before proceeding.
</div>
</div>
<div class="col-xxl-2 text-xxl-end">
<a class="btn btn-warning rounded-1 text-nowrap resolve-to-invite-link" target="_blank">Add PYRSS</a>
</div>
</div>
</div>
</div>
<div class="col-12 bg-body-tertiary">
<div class="row py-3 px-sm-3">
<div class="col-sm-4 col-xxl-3 mb-4 mb-sm-0">
<div class="d-flex align-items-center">
<img class="resolve-to-server-icon rounded-1 me-3 text-center" width="40">
<div>
<h5 class="resolve-to-server-name mb-0"></h5>
<h6 class="resolve-to-server-id small mb-0"></h6>
</div>
</div>
</div>
<div class="col-sm-8 col-xxl-9">
<ul class="nav nav-pills justify-content-sm-end" role="tablist">
<li class="nav-item me-1 me-lg-3" role="presentation">
<button id="subscriptionsTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#subscriptionsTabPane" type="button" aria-controls="subscriptionsTabPane" aria-selected="false">
<i class="bi bi-layers"></i>
<span class="ms-2 d-none d-lg-inline">Subscriptions</span>
</button>
</li>
<li class="nav-item me-1 me-lg-3" role="presentation">
<button id="filtersTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#filtersTabPane" type="button" aria-controls="filtersTabPane" aria-selected="false">
<i class="bi bi-funnel"></i>
<span class="ms-2 d-none d-lg-inline">Content Filters</span>
</button>
</li>
<li class="nav-item me-1 me-lg-3" role="presentation">
<button id="stylesTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#stylesTabPane" type="button" aria-controls="stylesTabPane" aria-selected="false">
<i class="bi bi-border-style"></i>
<span class="ms-2 d-none d-lg-inline">Message Styles</span>
</button>
</li>
<li class="nav-item me-lg-3" role="presentation">
<button id="contentTab" class="nav-link rounded-1" data-bs-toggle="tab" data-bs-target="#contentTabPane" type="button" aria-controls="contentTabPane" aria-selected="false">
<i class="bi bi-archive"></i>
<span class="ms-2 d-none d-lg-inline">Tracked Content</span>
</button>
</li>
<li class="nav-item me-0 dropdown">
<button type="button" class="nav-link dropdown-toggle rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-gear"></i>
</button>
<ul class="dropdown-menu">
<li>
<button type="button" class="js-serverUsersBtn dropdown-item">
<i class="bi bi-people"></i>
<span class="ms-2">Other Users</span>
</button>
</li>
<li>
<button type="button" class="js-serverHistoryBtn dropdown-item">
<i class="bi bi-clock"></i>
<span class="ms-2">Edit History</span>
</button>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<button type="button" class="js-eraseServerBtn dropdown-item text-danger">
<i class="bi-trash3"></i>
<span class="ms-2">Delete Data</span>
</button>
</li>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="col-12">
<div id="serverTabContent" class="tab-content">
<div id="subscriptionsTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="subscriptionsTab" tabindex="0">
{% include "home/tabs/subs.html" %}
</div>
<div id="filtersTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="filtersTab" tabindex="0">
{% include "home/tabs/filters.html" %}
</div>
<div id="stylesTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="stylesTab" tabindex="0">
{% include "home/tabs/styles.html" %}
</div>
<div id="contentTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="contentTab" tabindex="0">
{% include "home/tabs/content.html" %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% include "home/modals/editSub.html" %}
{% include "home/modals/editFilter.html" %}
{% include "home/modals/editStyle.html" %}
{% include "home/modals/editServer.html" %}
{% include "home/modals/modals.html" %}
{% endblock content %}
{% block javascript %}
<script id="serverItemTemplate" type="text/template">
<li>
<button type="button" class="sidebar-item">
<img src="" alt="" class="sidebar-item-image js-image">
<div class="sidebar-item-data">
<span class="js-name"></span>
<span class="js-id text-body-secondary font-monospace"></span>
</div>
</button>
</li>
</script>
<script id="serverItemIconTemplate" type="text/template">
<div class="dot-container m-1" data-bs-toggle="tooltip" data-bs-placement="right">
<i class="dot-icon bg-warning "></i>
</div>
</script>
<script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'home/js/index.js' %}"></script>
<script src="{% static 'home/js/modals.js' %}"></script>
<script src="{% static 'home/js/servers.js' %}"></script>
<script src="{% static 'home/js/tables.js' %}"></script>
<script src="{% static 'home/js/tabs/subs.js' %}"></script>
<script src="{% static 'home/js/tabs/filters.js' %}"></script>
<script src="{% static 'home/js/tabs/content.js' %}"></script>
<script src="{% static 'home/js/tabs/styles.js' %}"></script>
{% endblock javascript %}

View File

@ -0,0 +1,66 @@
<div id="filterFormModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<form id="filterForm" class="mb-0" novalidate>
<div class="modal-header border-bottom-0">
<h5 class="modal-title ms-2">
<span class="form-create">Add</span>
<span class="form-edit">Edit</span>
Content Filter
</h5>
</div>
<div class="modal-body p-4">
<div class="row">
<div class="col-12">
<div class="mb-4">
<label for="filterName" class="form-label">Name</label>
<input type="text" name="filterName" id="filterName" class="form-control rounded-1" data-field="name" tabindex="1">
<div class="form-text">Human-readable name for this entry.</div>
</div>
</div>
<div class="col-12">
<div class="mb-4">
<label for="filterMatchingAlgorithm" class="form-label">Matching Algorithm</label>
<select name="filterMatchingAlgorithm" id="filterMatchingAlgorithm" class="select-2" data-field="matching_algorithm" tabindex="2"></select>
<div class="form-text">The algorithm used to match against.</div>
</div>
</div>
<div class="col-12">
<div class="mb-4">
<label for="filterMatch" class="form-label">Match</label>
<input type="text" name="filterMatch" id="filterMatch" class="form-control rounded-1" data-field="match" tabindex="3">
<div class="form-text">The value to match against.</div>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch mb-4">
<label for="filterIsInsensitive" class="form-check-label">Case-Insensitive</label>
<input type="checkbox" name="filterIsInsensitive" id="filterIsInsensitive" class="form-check-input" data-field="is_insensitive" tabindex="4">
<div class="form-text">Should case-sensitivity be ignored?</div>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<label for="filterIsWhitelist" class="form-check-label">Whitelist Mode</label>
<input type="checkbox" name="filterIsWhitelist" id="filterIsWhitelist" class="form-check-input" data-field="is_whitelist" tabindex="5">
<div class="form-text">Overrides the default blacklist behaviour.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer border-top-0 px-4">
<button type="button" class="btn btn-danger rounded-1 me-auto ms-0 modal-del-btn form-edit" tabindex="6">
<i class="bi bi-trash3"></i>
</button>
<button type="submit" class="btn btn-primary rounded-1 me-0 px-4" tabindex="7">
<i class="bi bi-floppy"></i>
</button>
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3" data-bs-dismiss="modal" tabindex="8">
<i class="bi bi-arrow-return-right"></i>
</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -19,14 +19,19 @@
</div>
</div>
<div>
<div class="form-switch ps-0">
<label for="guildSettingsActive" class="form-check-label mb-2">Server Active?</label>
<br>
<input type="checkbox" name="guildSettingsActive" id="guildSettingsActive" class="form-check-input ms-0 mt-0">
<br>
<div class="form-text">Is this server active?</div>
<div class="form-check form-switch">
<label for="guildSettingsActive" class="form-check-label">Server Enabled</label>
<input type="checkbox" name="guildSettingsActive" id="guildSettingsActive" class="form-check-input" role="switch">
<div class="form-text">Disabled servers will not process Subscriptions.</div>
</div>
</div>
{% comment %}
<hr class="my-4">
<div>
<button type="button" class="btn btn-outline-primary">Reset All Publish Thresholds</button>
<div class="form-text">Subscription content predating the threshold won't be processed.</div>
</div>
{% endcomment %}
</div>
<div class="modal-footer px-4">
<button type="submit" class="btn btn-primary rounded-1 ms-3 me-0">Save Changes</button>

View File

@ -0,0 +1,108 @@
<div id="styleFormModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<form id="styleForm" class="mb-0" novalidate>
<div class="modal-header border-bottom-0">
<h5 class="modal-title ms-2">
<span class="form-create">Add</span>
<span class="form-edit">Edit</span>
Message Style
</h5>
</div>
<div class="modal-body p-4">
<div class="row">
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<label for="styleName" class="form-label">Name</label>
<input type="text" name="styleName" id="styleName" class="form-control rounded-1" data-field="name" tabindex="1">
<div class="form-text">Human-readable name for this entry.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<div class="colour-input"
data-id="styleEmbedColour"
data-label="Embed Colour"
data-helptext="Colour of the embed (if enabled)."
data-defaultcolour="#3498db"
data-field="colour">
</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="form-check form-switch mb-4">
<label for="styleIsEmbed" class="form-check-label">Use an Embed</label>
<input type="checkbox" name="styleIsEmbed" id="styleIsEmbed" class="form-check-input" data-field="is_embed" tabindex="2">
<div class="form-text">Display in an Embed, instead of plain text.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="form-check form-switch mb-4">
<label for="styleIsHyperlinked" class="form-check-label">Hyperlink the Title</label>
<input type="checkbox" name="styleIsHyperlinked" id="styleIsHyperlinked" class="form-check-input" data-field="is_hyperlinked" tabindex="3">
<div class="form-text">Click the title to go to the url.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="form-check form-switch mb-4">
<label for="styleShowAuthor" class="form-check-label">Show the Author</label>
<input type="checkbox" name="styleShowAuthor" id="styleShowAuthor" class="form-check-input" data-field="show_author" tabindex="4">
<div class="form-text">Show the content author if possible.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="form-check form-switch mb-4">
<label for="styleShowTimestamp" class="form-check-label">Show the Publish Date</label>
<input type="checkbox" name="styleShowTimestamp" id="styleShowTimestamp" class="form-check-input" data-field="show_timestamp" tabindex="5">
<div class="form-text">Show when the content was published.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="form-check form-switch mb-4">
<label for="styleShowImages" class="form-check-label">Show any Images</label>
<input type="checkbox" name="styleShowImages" id="styleShowImages" class="form-check-input" data-field="show_images" tabindex="6">
<div class="form-text">Show any found images.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="form-check form-switch mb-4">
<label for="styleFetchImages" class="form-check-label">Fetch missing Images</label>
<input type="checkbox" name="styleFetchImages" id="styleFetchImages" class="form-check-input" data-field="fetch_images" tabindex="7">
<div class="form-text">If images aren't found, try to fetch them.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="mb-4 mb-lg-0">
<label for="styleTitleMutator" class="form-label">Title Mutator</label>
<select name="styleTitleMutator" id="styleTitleMutator" class="select-2" data-field="title_mutator" tabindex="8">
<option value="">-----</option>
</select>
<div class="form-text">Modify the title in fun ways.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="">
<label for="styleDescriptionMutator" class="form-label">Description Mutator</label>
<select name="styleDescriptionMutator" id="styleDescriptionMutator" class="select-2" data-field="description_mutator" tabindex="9">
<option value="">-----</option>
</select>
<div class="form-text">Modify the description in fun ways.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer px-4 border-top-0">
<button type="button" class="btn btn-danger rounded-1 me-auto ms-0 modal-del-btn form-edit" tabindex="10">
<i class="bi bi-trash3"></i>
</button>
<button type="submit" class="btn btn-primary rounded-1 me-0 px-4" tabindex="11">
<i class="bi bi-floppy"></i>
</button>
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3" data-bs-dismiss="modal" tabindex="12">
<i class="bi bi-arrow-return-right"></i>
</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,119 @@
<div id="subFormModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<form id="subForm" class="mb-0 needs-validation" novalidate>
<div class="modal-header border-bottom-0">
<h5 class="modal-title ms-2">
<span class="form-create">Add</span>
<span class="form-edit">Edit</span>
Subscription
</h5>
</div>
<div class="modal-body p-4">
<input type="hidden" data-role="is-id">
<div class="row">
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<label for="subName" class="form-label">Name</label>
<input type="text" id="subName" name="subName" class="form-control rounded-1" required data-field="name" tabindex="1">
<div class="form-text">Human-readable name for this entry.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<label for="subUrl" class="form-label">URL</label>
<input type="url" id="subUrl" name="subUrl" class="form-control rounded-1" required data-field="url" tabindex="2">
<div class="form-text">Source of <a href="https://en.wikipedia.org/wiki/RSS" class="text-decoration-none" target="_blank">RSS</a> content.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<label for="subMessageStyle" class="form-label">Message Style</label>
<select name="subMessageStyle" id="subMessageStyle" class="select-2" data-field="message_style" data-default="firstOption" tabindex="3">
</select>
<div class="form-text">Appearance of delivered content.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<label for="subPubThreshold" class="form-label">Publish Threshold</label>
<input type="datetime-local" name="subPubThreshold" id="subPubThreshold" class="form-control rounded-1" required data-field="publish_threshold" tabindex="4">
<div class="form-text">Ignore content older than this date.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<label for="subChannels" class="form-label">Channels</label>
<select name="subChannels" id="subChannels" class="select-2" multiple data-field="channels" tabindex="5"></select>
<div class="form-text">Send content to these channels.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<label for="subFilters" class="form-label">Filters</label>
<select name="subFilters" id="subFilters" class="select-2" multiple data-field="filters" tabindex="6"></select>
<div class="form-text">Filter out unwanted content.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="mb-4 mb-lg-0">
<label for="subUniqueRules" class="form-label">Uniqueness Rules</label>
<select name="subUniqueRules" id="subUniqueRules" class="select-2" multiple required data-field="unique_rules" tabindex="7"></select>
<div class="form-text">Rules on telling content apart.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="form-check form-switch">
<label for="subActive" class="form-check-label">Enabled</label>
<input type="checkbox" id="subActive" name="subActive" class="form-check-input" data-field="active" tabindex="8" data-default="true">
<div class="form-text">Disabled Subscriptions will be ignored when processing content.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer px-4 border-top-0">
<button type="button" class="btn btn-outline-secondary rounded-1 me-auto ms-0 form-create" tabindex="10">
<i class="bi bi-dice-5"></i>
</button>
<button type="button" class="btn btn-danger rounded-1 me-3 ms-0 modal-del-btn form-edit" tabindex="11">
<i class="bi bi-trash3"></i>
</button>
<button type="button" class="btn btn-secondary rounded-1 me-auto ms-0 px-4 form-edit" data-bs-toggle="modal" data-bs-target="#subContentModal" tabindex="12">
<i class="bi bi-archive"></i>
</button>
<button type="submit" class="btn btn-primary rounded-1 me-0 px-4" tabindex="13">
<i class="bi bi-floppy"></i>
</button>
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3" data-bs-dismiss="modal" tabindex="14">
<i class="bi bi-arrow-return-right"></i>
</button>
</div>
</form>
</div>
</div>
</div>
<div id="subContentModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<div class="modal-header border-bottom-0">
<h5 class="modal-title ms-2">
<span>Subscription Content</span>
</h5>
</div>
<div class="modal-body p-4">
<p class="mb-0">
Here is the content produced by this subscription.
</p>
<div class="table-responsive">
<table class="table table-hover align-middle"></table>
</div>
</div>
<div class="modal-footer border-top-0">
<button type="button" class="btn btn-secondary rounded-1 me-0 px-4" data-bs-toggle="modal" data-bs-target="#subFormModal">
<i class="bi bi-arrow-return-right"></i>
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
<div id="confirmModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<div class="modal-header border-bottom-0">
<h5 class="modal-title mx-2"></h5>
</div>
<div class="modal-body p-4">
<p class="mb-0"></p>
</div>
<div class="modal-footer border-top-0 px-4">
<button type="button" class="btn rounded-1 modal-confirm-btn" tabindex="1">
<i class="bi"></i>
</button>
<button type="button" class="btn btn-secondary rounded-1 ms-3 ms-0 px-4 modal-dismiss-btn" tabindex="2">
<i class="bi bi-arrow-return-right"></i>
</button>
</div>
</div>
</div>
</div>
<div id="customModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<div class="modal-header border-bottom-0">
<h5 class="modal-title mx-2"></h5>
</div>
<div class="modal-body p-4"></div>
<div class="modal-footer border-top-0 px-4"></div>
</div>
</div>
</div>

View File

@ -0,0 +1,112 @@
{% load static %}
<div class="sidebar-backdrop"></div>
<div class="sidebar">
<div class="sidebar-header">
<a href="/" class="sidebar-header-link me-auto">
<img src="{% static '/images/pyrss_logo.webp' %}" alt="pyrss logo" class="sidebar-logo">
<span class="sidebar-title font-atkinson-hyperlegible">PYRSS</span>
</a>
<button type="button" class="btn-close"></button>
</div>
<hr class="sidebar-divider">
<ul id="serverList" class="sidebar-content overflow-y-auto">
<li class="server-rate-limit">
<p class="text-danger">
<span>Failed to fetch results - you are being rate limited by Discord.</span>
<i class="bi bi-question-circle-fill" data-bs-toggle="tooltip" data-bs-title="Discord rate-limits when requests are made in rapid succession."></i>
</p>
<button type="button" class="sidebar-btn sidebar-retry-btn w-100 rounded-1">
<i class="bi bi-arrow-clockwise me-2"></i>
<span>Retry</span>
</button>
</li>
{% for i in "01234567890"|make_list %}
<li class="sidebar-loading">
<div class="sidebar-placeholder placeholder-wave">
<div class="sidebar-placeholder-image placeholder"></div>
<div class="sidebar-placeholder-data">
<span class="placeholder mb-3 w-75"></span>
<span class="placeholder {% if forloop.counter0|divisibleby:2 %}w-100{% else %}w-50{% endif %}"></span>
</div>
</div>
</li>
{% endfor %}
</ul>
<hr class="sidebar-divider">
<div class="sidebar-footer">
<div class="sidebar-btn text-start flex-grow-1 dropdown p-0">
<button type="button" class="sidebar-menu-btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<img src="{{ request.user.avatar_url }}" alt="user icon" class="sidebar-avatar">
<strong class="text-truncate">{{ request.user.global_name }}</strong>
</button>
<ul class="dropdown-menu">
<li>
<a href="https://gitea.cor.bz/corbz/PYRSS-Website" class="dropdown-item" target="_blank">
<i class="bi bi-git me-2"></i>
<span>Source Code</span>
</a>
</li>
<li>
<a href="https://gitea.cor.bz/corbz/PYRSS-Website/wiki" class="dropdown-item" target="_blank">
<i class="bi bi-question-lg me-2"></i>
<span>Help</span>
</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
{% if request.user.is_superuser %}
<li>
<a href="/admin/" class="dropdown-item" target="_blank">
<i class="bi bi-person-check-fill me-2"></i>
<span>Admin</span>
</a>
</li>
{% endif %}
<li>
<form action="/logout/" method="post" class="m-0">
{% csrf_token %}
<button type="submit" class="dropdown-item">
<i class="bi bi-arrow-left me-2"></i>
<span>Logout</span>
</button>
</form>
</li>
</ul>
</div>
<div class="sidebar-btn sidebar-mini-btn dropdown">
<button type="button" class="js-themeMenuBtn sidebar-menu-btn px-3" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-sun"></i>
</button>
<ul class="theme-menu dropdown-menu dropdown-menu-center">
<li>
<input type="radio" name="themeToggle" id="themeToggleLight" class="btn-check" value="light" autocomplete="off">
<label for="themeToggleLight" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Light Theme">
<i class="bi bi-sun"></i>
</label>
</li>
<li>
<input type="radio" name="themeToggle" id="themeToggleDark" class="btn-check" value="dark" autocomplete="off">
<label for="themeToggleDark" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Dark Theme">
<i class="bi bi-moon-stars"></i>
</label>
</li>
<li>
<input type="radio" name="themeToggle" id="themeToggleAuto" class="btn-check" value="auto" autocomplete="off">
<label for="themeToggleAuto" class="theme-btn" role="button" data-bs-toggle="tooltip" data-bs-title="Browser Preferred Theme">
<i class="bi bi-circle-half"></i>
</label>
</li>
</ul>
</div>
<button type="button" class="js-pinSidebar sidebar-btn sidebar-mini-btn d-flex d-lg-none">
<i class="bi bi-pin-angle"></i>
</button>
</div>
</div>
<button type="button" class="reveal-sidebar-btn rounded-1 btn btn-lg btn-primary shadow">
<i class="bi bi-list"></i>
</button>

View File

@ -0,0 +1,75 @@
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
<div class="col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<div class="table-search-group mb-lg-0 mb-3">
<label for="searchForContent" class="table-search-label">
<i class="bi bi-search"></i>
</label>
<input type="search" id="searchForContent" class="table-search-input disable-while-loading" placeholder="search">
</div>
</div>
<div class="col-md-6 col-lg-7 col-xl-8 col-xxl-9 text-md-end table-button-controls">
<div class="d-inline-block">
<div class="dropdown table-sort-dropdown">
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-sort-alpha-up"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Sort By</h6></li>
</ul>
</div>
</div>
<div class="d-inline-block">
<div class="dropdown table-filter-dropdown">
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Filter By</h6></li>
</ul>
</div>
</div>
<div class="d-inline-block">
<button type="button" class="table-refresh-btn btn btn-outline-secondary rounded-1 disable-while-loading">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="d-inline-block">
<button type="button" class="table-del-btn btn btn-danger rounded-1" disabled>
<i class="bi bi-trash3"></i>
</button>
</div>
</div>
</div>
<div class="js-tableBody table-responsive my-3 px-sm-3">
<table id="contentTable" class="table table-hover"></table>
</div>
<div class="js-tableControls row px-sm-3 mb-4">
<div class="col-lg-6">
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
<ul class="pagination mb-0">
<li class="page-item">
<button type="button" class="page-link page-prev rounded-start-1">
<i class="bi bi-chevron-left"></i>
</button>
</li>
<li class="page-item">
<button type="button" class="page-link page-next rounded-end-1">
<i class="bi bi-chevron-right"></i>
</button>
</li>
</ul>
</nav>
</div>
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
<label for="contentTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
<select name="contentTablePageSizer" id="contentTablePageSizer" class="select-2 table-page-sizer disable-while-loading">
<option value="1">1&emsp;</option>
<option value="10" selected>10&emsp;</option>
<option value="15">15&emsp;</option>
<option value="20">20&emsp;</option>
<option value="25">25&emsp;</option>
</select>
<span class="ms-2">of&nbsp;</span>
<span class="pageinfo-total text-nowrap">10</span>
</div>
</div>

View File

@ -0,0 +1,80 @@
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
<div class="col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<div class="table-search-group mb-lg-0 mb-3">
<label for="searchForFilter" class="table-search-label">
<i class="bi bi-search"></i>
</label>
<input type="search" id="searchForFilter" class="table-search-input" placeholder="search">
</div>
</div>
<div class="col-md-6 col-lg-7 col-xl-8 col-xxl-9 text-md-end table-search-buttons">
<div class="d-inline-block">
<button type="button" class="table-new-btn btn btn-primary rounded-1">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="d-inline-block">
<div class="dropdown table-sort-dropdown">
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-sort-alpha-up"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Sort By</h6></li>
</ul>
</div>
</div>
<div class="d-inline-block">
<div class="dropdown table-filter-dropdown">
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Filter By</h6></li>
</ul>
</div>
</div>
<div class="d-inline-block">
<button type="button" class="table-refresh-btn btn btn-outline-secondary rounded-1 disable-while-loading">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="d-inline-block">
<button type="button" class="table-del-btn btn btn-danger rounded-1" disabled>
<i class="bi bi-trash3"></i>
</button>
</div>
</div>
</div>
<div class="js-tableBody table-responsive my-3 px-sm-3">
<table id="filterTable" class="table table-hover"></table>
</div>
<div class="js-tableControls row px-sm-3 mb-4">
<div class="col-lg-6">
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
<ul class="pagination mb-0">
<li class="page-item">
<button type="button" class="page-link page-prev rounded-start-1">
<i class="bi bi-chevron-left"></i>
</button>
</li>
<li class="page-item">
<button type="button" class="page-link page-next rounded-end-1">
<i class="bi bi-chevron-right"></i>
</button>
</li>
</ul>
</nav>
</div>
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
<label for="filterTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
<select name="filterTablePageSizer" id="filterTablePageSizer" class="select-2 table-page-sizer disable-while-loading">
<option value="1">1&emsp;</option>
<option value="10" selected>10&emsp;</option>
<option value="15">15&emsp;</option>
<option value="20">20&emsp;</option>
<option value="25">25&emsp;</option>
</select>
<span class="ms-2">of&nbsp;</span>
<span class="pageinfo-total text-nowrap">10</span>
</div>
</div>

View File

@ -0,0 +1,79 @@
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
<div class="col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<div class="table-search-group mb-lg-0 mb-3">
<label for="searchForMessageStyle" class="table-search-label">
<i class="bi bi-search"></i>
</label>
<input type="search" id="searchForMessageStyle" class="table-search-input" placeholder="search">
</div>
</div>
<div class="col-md-6 col-lg-7 col-xl-8 col-xxl-9 text-md-end table-button-controls">
<div class="d-inline-block">
<button type="button" class="table-new-btn btn btn-primary rounded-1">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="d-inline-block">
<div class="dropdown table-sort-dropdown">
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-sort-alpha-up"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Sort By</h6></li>
</ul>
</div>
</div>
<div class="d-inline-block">
<div class="dropdown table-filter-dropdown">
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Filter By</h6></li>
</ul>
</div>
</div>
<div class="d-inline-block">
<button type="button" class="table-refresh-btn btn btn-outline-secondary rounded-1">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="d-inline-block">
<button type="button" class="table-del-btn btn btn-danger rounded-1" disabled>
<i class="bi bi-trash3"></i>
</button>
</div>
</div>
</div>
<div class="js-tableBody table-responsive my-3 px-sm-3">
<table id="styleTable" class="table table-hover"></table>
</div>
<div class="js-tableControls row px-sm-3 mb-4">
<div class="col-lg-6">
<nav class="table-pagination mb-4 mb-lg-0">
<ul class="pagination mb-0">
<li class="page-item">
<button type="button" class="page-link page-prev rounded-start-1">
<i class="bi bi-chevron-left"></i>
</button>
</li>
<li class="page-item">
<button type="button" class="page-link page-next rounded-end-1">
<i class="bi bi-chevron-right"></i>
</button>
</li>
</ul>
</nav>
</div>
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
<label for="styleTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
<select name="styleTablePageSizer" id="styleTablePageSizer" class="select-2 table-page-sizer">
<option value="10" selected>10&emsp;</option>
<option value="15">15&emsp;</option>
<option value="20">20&emsp;</option>
<option value="25">25&emsp;</option>
</select>
<span class="ms-2">of&nbsp;</span>
<span class="pageinfo-total text-nowrap">10</span>
</div>
</div>

View File

@ -0,0 +1,80 @@
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
<div class="col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<div class="table-search-group mb-lg-0 mb-3">
<label for="searchForSubscription" class="table-search-label">
<i class="bi bi-search"></i>
</label>
<input type="search" id="searchForSubscription" class="table-search-input" placeholder="search">
</div>
</div>
<div class="col-md-6 col-lg-7 col-xl-8 col-xxl-9 text-md-end table-button-controls">
<div class="d-inline-block">
<button type="button" class="table-new-btn btn btn-primary rounded-1">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="d-inline-block">
<div class="dropdown table-sort-dropdown">
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-sort-alpha-up"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Sort By</h6></li>
</ul>
</div>
</div>
<div class="d-inline-block">
<div class="dropdown table-filter-dropdown">
<button type="button" class="btn btn-secondary rounded-1" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Filter By</h6></li>
</ul>
</div>
</div>
<div class="d-inline-block">
<button type="button" class="table-refresh-btn btn btn-outline-secondary rounded-1 disable-while-loading">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="d-inline-block">
<button type="button" class="table-del-btn btn btn-danger rounded-1" disabled>
<i class="bi bi-trash3"></i>
</button>
</div>
</div>
</div>
<div class="js-tableBody table-responsive my-3 px-sm-3">
<table id="subTable" class="table table-hover"></table>
</div>
<div class="js-tableControls row px-sm-3 mb-4">
<div class="col-lg-6">
<nav class="table-pagination mb-4 mb-lg-0 table-pagination-group"> <!-- TODO: continue here -->
<ul class="pagination mb-0">
<li class="page-item">
<button type="button" class="page-link page-prev rounded-start-1">
<i class="bi bi-chevron-left"></i>
</button>
</li>
<li class="page-item">
<button type="button" class="page-link page-next rounded-end-1">
<i class="bi bi-chevron-right"></i>
</button>
</li>
</ul>
</nav>
</div>
<div class="col-lg-6 d-flex align-items-center justify-content-lg-end">
<label for="subTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
<select name="subTablePageSizer" id="subTablePageSizer" class="select-2 table-page-sizer disable-while-loading">
<option value="1">1&emsp;</option>
<option value="10" selected>10&emsp;</option>
<option value="15">15&emsp;</option>
<option value="20">20&emsp;</option>
<option value="25">25&emsp;</option>
</select>
<span class="ms-2">of&nbsp;</span>
<span class="pageinfo-total text-nowrap">10</span>
</div>
</div>

View File

@ -3,8 +3,10 @@
from django.urls import path
from django.contrib.auth.decorators import login_required
from .views import IndexView
from .views import IndexView, GuildsView, ChannelsView
urlpatterns = [
path("", login_required(IndexView.as_view()), name="index"),
path("generate-servers/", GuildsView.as_view(), name="generate-servers"),
path("generate-channels/", ChannelsView.as_view(), name="generate-channels"),
]

View File

@ -1,6 +1,19 @@
# -*- encoding: utf-8 -*-
from django.views.generic import TemplateView
import logging
import httpx
from django.conf import settings
from asgiref.sync import sync_to_async
from django.shortcuts import redirect
from django.http import JsonResponse, HttpResponseNotFound, HttpResponseNotAllowed
from django.views.generic import TemplateView, View
from apps.home.models import Server, DiscordChannel
from apps.api.serializers import ServerSerializer
from apps.authentication.models import DiscordUser, ServerMember
log = logging.getLogger(__name__)
class IndexView(TemplateView):
@ -9,3 +22,212 @@ class IndexView(TemplateView):
"""
template_name = "home/index.html"
@sync_to_async
def get_user_access_token(user: DiscordUser) -> str:
return user.access_token
@sync_to_async
def is_user_authenticated(user: DiscordUser) -> bool:
return user.is_authenticated
class GuildsView(View):
"""
Fetches the related guilds to the currently authenticated user from Discord.
Will return a filtered list of the results, excluding servers where the
user isn't an administrator or owner.
Valid servers will also be stored in the database for future reference,
along-side the user-server relationship as a member.
"""
async def get(self, request, *args, **kwargs):
if not await is_user_authenticated(request.user):
return redirect("/oauth2/login")
access_token = await get_user_access_token(request.user)
guilds_data, status = await self._get_guilds_data(access_token)
# Send back the error data if status is bad
if status != 200:
return JsonResponse(guilds_data, status=status, safe=False)
cleaned_guilds_data = await self._clean_guilds_data(request.user, guilds_data)
return JsonResponse(cleaned_guilds_data, safe=False)
async def _get_guilds_data(self, access_token: str) -> tuple[list[dict], int]:
"""
Returns the raw guild data and a response status code
from the Discord API.
"""
async with httpx.AsyncClient() as client:
response = await client.get(
url=f"{settings.DISCORD_API_URL}/users/@me/guilds",
headers={"Authorization": f"Bearer {access_token}"}
)
return response.json(), response.status_code
async def _clean_guilds_data(self, user: DiscordUser, guilds_data: list[dict]) -> list[dict]:
"""
Returns a filtered copy of the given `guilds_data`, without the guilds
where the given `user` is not an administrator or owner of.
Also, for each guild, creates/updates an object to represent it, and
another to represent the user/guild relationship as a member.
"""
cleaned_guilds_data = []
for item in guilds_data:
cleaned_data = await self._setup_server(user, item)
if cleaned_data:
cleaned_guilds_data.append(cleaned_data)
return cleaned_guilds_data
async def _setup_server(self, user: DiscordUser, item: dict) -> dict[str] | None:
"""
Create or update a server and user's membership to said server.
Returns the cleaned server data, or NoneType if the user isn't
an admin or owner.
"""
# Collect some commonly used server data
server_id = item["id"]
is_owner = item["owner"]
permissions = item["permissions"]
admin_perm = 1 << 3
# Skip servers where the user isn't an administrator or owner.
# If an older member object exists, delete that too.
if not ((int(permissions) & admin_perm) == admin_perm or is_owner):
await self._try_delete_member(user, server_id)
return
# Create or update an existing server matching the given ID
server, created = await Server.objects.aupdate_or_create(
id=server_id,
defaults={
"name": item["name"],
"icon_hash": item["icon"]
}
)
# Create or update a member object linking the user to the server
await ServerMember.objects.aupdate_or_create(
user=user,
server=server,
defaults={
"permissions": permissions,
"is_owner": is_owner
}
)
return ServerSerializer(server).data
@staticmethod
async def _try_delete_member(user: DiscordUser, server_id: int):
"""
Attempt to delete any existing server member linked to the given
server id.
"""
try:
member = await ServerMember.objects.aget(user=user, server_id=server_id)
await member.adelete()
except ServerMember.DoesNotExist:
pass
class ChannelsView(View):
async def get(self, request, *args, **kwargs):
if not await is_user_authenticated(request.user):
return redirect("/oauth2/login")
guild_id = request.GET.get("guild")
try: server = await Server.objects.aget(pk=guild_id)
except Server.DoesNotExist: return HttpResponseNotFound("Server not found.")
if not await ServerMember.objects.filter(server=server, user=request.user).aexists():
return HttpResponseNotAllowed("You aren't a member of this server.")
channels_data, status = await self._get_channel_data(guild_id)
# Not authorized means the bot isn't operational, we need to save that
if status == 403:
server.is_bot_operational = False
await server.asave()
# Send back the error data if status is bad
if status != 200:
return JsonResponse(channels_data, status=status, safe=False)
# Because the status is 200, we know the Bot is functional, set that param
server.is_bot_operational = True
await server.asave()
cleaned_channels_data = await self._clean_channels_data(server, channels_data)
await self._cleanup_dead_channels(server, cleaned_channels_data)
return JsonResponse(cleaned_channels_data, safe=False)
async def _get_channel_data(self, guild_id: int) -> tuple[list[dict], int]:
"""
Returns the raw channel data and a response status code
from the Discord API.
"""
async with httpx.AsyncClient() as client:
response = await client.get(
url=f"{settings.DISCORD_API_URL}/guilds/{guild_id}/channels",
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
)
return response.json(), response.status_code
async def _clean_channels_data(self, server: Server, channels_data) -> list[dict]:
"""
Returns a sorted & cleaned list of channel data, also performs a
database setup for each channel to be stored.
"""
cleaned_channels_data = []
for item in channels_data:
cleaned_data = await self._setup_channel(server, item)
if cleaned_data:
cleaned_channels_data.append(cleaned_data)
cleaned_channels_data.sort(key=lambda ch: ch.get("position"))
return cleaned_channels_data
async def _setup_channel(self, server: Server, data: dict) -> dict:
"""
Create or update an instance of DiscordChannel representing the given
`data` dictionary, returns the data or `NoneType` if `data['type'] != 0`.
"""
# Type 0 = TextChannel, the only one we want
if data.get("type") != 0:
return
await DiscordChannel.objects.aupdate_or_create(
id=data["id"],
server=server,
defaults={
"name": data["name"],
"is_nsfw": data["nsfw"]
}
)
return data
async def _cleanup_dead_channels(self, server: Server, channel_data: list[dict]):
"""
Deletes any unused instances of `DiscordChannel` against the given server.
"""
channel_ids = [item["id"] for item in channel_data]
count, _ = await DiscordChannel.objects.filter(server=server).exclude(id__in=channel_ids).adelete()
log.info("Deleted %s dead DiscordChannel object(s)", count)

View File

@ -1,85 +0,0 @@
@keyframes bump {
0% {
transform: translateY(0);
}
50% {
transform: translateY(5px); /* Adjust the height of the bump */
}
100% {
transform: translateY(0);
}
}
.bump {
animation: bump .2s ease-out;
}
.server-item.active img {
border-radius: .75rem !important;
}
.server-item-selector:hover img, .server-item-selector:focus img, .server-item-selector:focus-visible img {
border-radius: .75rem !important;
}
.server-item-selector:active, .server-item-selector:focus, .server-item-selector:focus-visible {
animation: bump .2s ease-out;
}
.server-item-selector img {
transition: border-radius .15s ease-in;
}
/* widths */
.mw-10rem {
max-width: 10rem;
}
.col-switch-width {
width: 3.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
}
/* tables */
.table {
color: var(--bs-body-color) !important;
}
.table tbody tr.selected > * {
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
color: var(--bs-body-color) !important;
}
.table.dataTable > tbody > tr.selected a {
color: var(--bs-link-color) !important;
}
/* Fuck ugly <td> height fix */
td {
height: 1px;
text-wrap: nowrap;
}
td > .btn-link {
padding-left: 0;
}
@-moz-document url-prefix() {
tr {
height: 100%;
}
td {
height: 100%;
}
}
#serverTabs .nav-link {
border-radius: 0;
}
#serverTabs .nav-link:not(.active) {
color: var(--bs-text-body);
}

View File

@ -1,174 +0,0 @@
async function ajaxRequest(url, method, data) {
const options = {
url: url,
method: method,
beforeSend: function(xhr) {
xhr.setRequestHeader("X-CSRFToken", CSRF_MiddlewareToken);
}
}
if (data) {
options.data = data;
options.processData = false;
options.contentType = false;
}
try {
const response = await $.ajax(options);
return response
}
catch (error) {
return error
}
}
function makeQuerystring(filters, sort) {
let querystring = "?";
for (key in filters) {
querystring += `${key}=${filters[key]}&`;
}
return sort ? querystring += `ordering=${sort}` : querystring;
}
// Saved Guilds
async function getSavedGuilds() {
return await ajaxRequest("/api/saved-guilds/", "GET");
}
async function getSavedGuild(id) {
return await ajaxRequest(`/api/saved-guilds/${id}/`, "GET");
}
async function newSavedGuild(formData) {
return await ajaxRequest("/api/saved-guilds/", "POST", formData);
}
async function deleteSavedGuild(id) {
return await ajaxRequest(`/api/saved-guilds/${id}/`, "DELETE");
}
// Loading Guilds
async function loadGuilds() {
return await ajaxRequest("/guilds/", "GET");
}
// Loading Channels
async function loadChannels(guildId) {
return await ajaxRequest(`/channels?guild=${guildId}`, "GET");
}
// Subscriptions
async function getSubscriptions(filters, sort) {
let querystring = makeQuerystring(filters, sort);
return await ajaxRequest(`/api/subscription/${querystring}`, "GET");
}
async function getSubscription(id) {
return await ajaxRequest(`/api/subscription/${id}/`, "GET");
}
async function newSubscription(formData) {
return await ajaxRequest("/api/subscription/", "POST", formData);
}
async function deleteSubscription(id) {
return await ajaxRequest(`/api/subscription/${id}/`, "DELETE");
}
async function editSubscription(id, formData) {
return await ajaxRequest(`/api/subscription/${id}/`, "PUT", formData);
}
async function getSubscriptionOptions() {
return await ajaxRequest("/api/subscription/", "OPTIONS")
}
// SubChannels
async function getSubChannels(subscriptionId) {
return await ajaxRequest(`/api/subchannel/?subscription=${subscriptionId}`, "GET");
}
async function getSubChannel(id) {
return await ajaxRequest(`/api/subchannel/${id}/`, "GET");
}
async function newSubChannel(formData) {
return await ajaxRequest("/api/subchannel/", "POST", formData);
}
async function deleteSubChannel(id) {
return await ajaxRequest(`/api/subchannel/${id}/`, "DELETE")
}
async function deleteSubChannels(subscriptionId) {
return await ajaxRequest(`/api/subscription/${subscriptionId}/subchannels/`, "DELETE");
}
// Filters
async function getFilters(filters, sort) {
let querystring = makeQuerystring(filters, sort);
return await ajaxRequest(`/api/filter/${querystring}`, "GET");
}
async function getFilter(id) {
return await ajaxRequest(`/api/filter/${id}/`, "GET");
}
async function newFilter(formData) {
return await ajaxRequest("/api/filter/", "POST", formData);
}
async function deleteFilter(id) {
return await ajaxRequest(`/api/filter/${id}/`, "DELETE");
}
async function editFilter(id, formData) {
return await ajaxRequest(`/api/filter/${id}/`, "PUT", formData);
}
async function getFilterOptions() {
return await ajaxRequest("/api/filter/", "OPTIONS")
}
// Tracked Content
async function getTrackedContent(filters, sort) {
let querystring = makeQuerystring(filters, sort);
url = `/api/tracked-content/${querystring}`;
return await ajaxRequest(url, "GET");
}
async function deleteTrackedContent(guid) {
const encodedGuid = encodeURIComponent(guid);
return await ajaxRequest(`/api/tracked-content/${encodedGuid}/`, "DELETE");
}
async function getTrackedContentOptions() {
return await ajaxRequest("/api/tracked-content/", "OPTIONS")
}
// Mutators
async function getMutators() {
return await ajaxRequest("/api/article-mutator/?page_size=25", "GET");
}
// guild settings
async function getGuildSettings(guildId) {
return await ajaxRequest(`/api/guild-settings/?guild_id=${guildId}`, "GET");
}
async function editGuildSettings(id, formData) {
return await ajaxRequest(`/api/guild-settings/${id}/`, "PUT", formData);
}

View File

@ -1,63 +0,0 @@
$("#themeToggle").on("click", function() {
const currentTheme = $("body").attr("data-bs-theme");
const newTheme = currentTheme === "light" ? "dark" : "light";
updateTheme(newTheme);
});
function updateTheme(theme) {
$("body").attr("data-bs-theme", theme);
localStorage.setItem("theme", theme);
}
function getCurrentDateTime() {
var now = new Date();
var year = now.getFullYear();
var month = String(now.getMonth() + 1).padStart(2, '0');
var day = String(now.getDate()).padStart(2, '0');
var hours = String(now.getHours()).padStart(2, '0');
var minutes = String(now.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// Sanitise a given string to remove HTML, making it DOM safe.
function sanitise(string) {
if (typeof string !== "string") {
return string;
}
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
"/": '&#x2F;',
};
const reg = /[&<>"'/]/ig;
return string.replace(reg, (match) => map[match]);
}
$(document).ready(function() {
// Activate all tooltips
$('[data-bs-toggle="tooltip"]').tooltip();
// Activate select2s
$(".select-2").each(function() {
var dropdownParent = $(this).attr("data-dropdownparent");
$(this).select2({
theme: "bootstrap",
minimumResultsForSearch: 10,
dropdownParent: dropdownParent
});
});
// Activate datepickers
// $(".input-group.date").datepicker({format: "yyyy-mm-dd"});
// Load theme
var theme = localStorage.getItem("theme");
if (theme === null)
theme = "light";
updateTheme(theme);
});

View File

@ -1,255 +0,0 @@
var contentTable;
contentOptions = null;
channelResolveInterval = null;
async function initContentTable() {
contentOptions = await getTrackedContentOptions();
await initTable("#contentTabPane", "contentTable", loadContent, null, deleteSelectedContent, contentOptions);
contentTable = $("#contentTable").DataTable({
info: false,
paging: false,
ordering: false,
searching: false,
autoWidth: false,
order: [],
select: {
style: "multi+shift",
selector: 'th:first-child input[type="checkbox"]'
},
columnDefs: [
{ orderable: false, targets: "no-sort" },
{
targets: 0,
checkboxes: { selectRow: true }
}
],
columns: [
{
// Select row checkbox column
title: '<input type="checkbox" class="form-check-input table-select-all" />',
data: null,
orderable: false,
className: "text-center col-switch-width",
render: function() {
return '<input type="checkbox" class="form-check-input table-select-row" />'
}
},
{ data: "id", visible: false },
{
title: "GUID",
data: "guid",
className: "text-truncate mw-10rem",
},
{
title: "Name",
data: "title",
className: "text-truncate",
render: function(data, type, row) {
const title = sanitise(data);
const url = sanitise(row.url);
return `<a href="${url}" class="btn btn-link text-start text-decoration-none" target="_blank">${title}</a>`
}
},
{
title: "Subscription",
data: "subscription.name",
className: "text-nowrap",
render: function(data, type, row) {
const subName = sanitise(data);
return `<button type="button" onclick="goToSubscription(${row.subscription.id})" class="btn btn-link text-start text-decoration-none">${subName}</button>`
}
},
{
title: "Blocked",
data: "blocked",
className: "text-center col-1",
render: function(data) {
return data ? `<i class="bi bi-check-lg text-success"></i>` : ""
}
},
{
title: "Channel",
data: "channel_id",
className: "text-start",
render: function(data, type, row) {
const channelId = sanitise(data);
const messageId = sanitise(row.message_id);
return `<div class="resolve-channel-name text-center" data-channel-id="${channelId}" data-msg-id="${messageId}">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>`;
}
},
{
title: "Created",
data: "creation_datetime",
className: "text-nowrap",
render: function(data, type) {
let dateTime = new Date(data);
return $(`
<span data-bs-trigger="hover focus"
data-bs-html="true"
data-bs-custom-class="text-center"
data-bs-toggle="popover"
data-bs-content="${formatStringDate(dateTime, "%a, %D %B, %Y<br>%H:%M:%S")}">
${formatStringDate(dateTime, "%D, %b %Y")}
</span>
`).popover()[0];
}
},
{
orderable: false,
className: "p-0",
render: function(data, type, row) {
const embedColour = sanitise(row.subscription.embed_colour);
return `<div class="h-100" style="background-color: #${embedColour}; width: .25rem;">&nbsp;</div>`
}
}
]
});
bindTableCheckboxes("#contentTable", contentTable, "#contentTabPane .table-del-btn");
contentTable.on("draw", function() {
restartResolveChannelNamesTask();
});
}
// #region Resolve Channels
function restartResolveChannelNamesTask() {
clearInterval(channelResolveInterval);
startResolveChannelNamesTask();
}
function startResolveChannelNamesTask() {
const guildId = getCurrentlyActiveServer().guild_id;
channelResolveInterval = setInterval(function() {
if (resolveChannelNames(guildId))
clearInterval(channelResolveInterval);
}, 50)
}
function resolveChannelNames(guildId) {
if (!discordChannels.length) {
return false
}
$(".resolve-channel-name").each(function() {
const channelId = $(this).data("channel-id");
const messageId = $(this).data("msg-id");
console.log(channelId + " " + messageId);
const channel = discordChannels.find(channel => channel.value === channelId);
if (channel) {
const href = `https://discord.com/channels/${guildId}/${channelId}/${messageId}/`;
$(this).replaceWith(
$("<a>").text(channel.text)
.attr("href", href)
.attr("target", "_blank")
.addClass("btn btn-link text-start text-decoration-none text-nowrap")
);
}
});
return true;
}
// #endregion
async function goToSubscription(subId) {
$("#subscriptionsTab").click();
await showEditSubModal(subId);
}
// #region Delete Content
async function deleteSelectedContent() {
const rows = contentTable.rows(".selected").data().toArray();
const names = rows.map(row => { return row.title });
const namesString = arrayToHtmlList(names, true).prop("outerHTML");
const isMany = names.length > 1;
await confirmationModal(
`Delete ${isMany ? "Many Tracked Contents" : "a Tracked Content"}`,
`Do you wish to permanently delete ${isMany ? "these" : "this"} <b>${names.length}</b> Tracked Content${isMany ? "s" : ""}?<br><br>${namesString}`,
"danger",
async () => {
rows.forEach(async row => { await deleteTrackedContent(row.id) });
showToast(
"danger",
`Deleted ${names.length} Content${isMany ? "s" : ""}`,
`${arrayToHtmlList(names, false).prop("outerHTML")}`,
12000
);
// Multi-deletion can take time, this timeout ensures the refresh is accurate
setTimeout(async () => {
await loadContent(getCurrentlyActiveServer().guild_id);
}, 600);
},
null
);
}
// #endregion
function clearExistingContentRows() {
$("#contentTable thead .table-select-all").prop("checked", false).prop("indeterminate", false);
contentTable.clear().draw(false);
}
$("#contentTabPane").on("click", ".table-refresh-btn", async function() {
await loadContent(getCurrentlyActiveServer().guild_id);
});
// #region Load Content
async function loadContent(guildId) {
if (!guildId)
return;
setTableFilter("contentTable", "subscription__guild_id", guildId);
ensureTablePagination("contentTable");
$("#contentTabPane .table-del-btn").prop("disabled", true);
clearExistingContentRows();
try {
var content = await getTrackedContent(tableFilters["contentTable"], tableSorts["contentTable"]);
contentTable.rows.add(content.results).draw(false);
}
catch (err) {
console.error(err);
showToast("danger", `Error loading Tracked Content: HTTP ${err.status}`, err, 15000);
return;
}
updateTableContainer(
"contentTabPane",
tableFilters["contentTable"]["page"],
tableFilters["contentTable"]["page_size"],
content.results.length,
content.count,
content.next,
content.previous
);
$("#contentTable thead .table-select-all").prop("disabled", content.results.length === 0);
console.debug(`loaded filters, ${content.results.length} found`)
}
$(document).on("selectedServerChange", async function() {
const activeServer = getCurrentlyActiveServer();
await loadContent(activeServer.guild_id);
});
// #endregion

View File

@ -1,311 +0,0 @@
var filtersTable;
filterOptions = null;
// Create filters table
async function initFiltersTable() {
filterOptions = await getFilterOptions();
await initTable("#filtersTabPane", "filtersTable", loadFilters, showEditFilterModal, deleteSelectedFilters, filterOptions);
filtersTable = $("#filtersTable").DataTable({
info: false,
paging: false,
ordering: false,
searching: false,
autoWidth: false,
order: [],
select: {
style: "multi+shift",
selector: 'th:first-child input[type="checkbox"]'
},
columnDefs: [
{ orderable: false, targets: "no-sort" },
{
targets: 0,
checkboxes: { selectRow: true }
}
],
columns: [
{
// Select row checkbox column
title: '<input type="checkbox" class="form-check-input table-select-all" />',
data: null,
orderable: false,
className: "text-center col-switch-width",
render: function() {
return '<input type="checkbox" class="form-check-input table-select-row" />'
}
},
{ title: "ID", data: "id", visible: false },
{
title: "Name",
data: "name",
render: function(data, type, row) {
const name = sanitise(data);
return `<button type="button" onclick="showEditFilterModal(${row.id})" class="btn btn-link text-start text-decoration-none">${name}</button>`
}
},
{
title: "Matching Algorithm",
data: "matching_algorithm",
render: function(data) {
switch (data) {
case 1: return "Any Word";
case 2: return "All Words";
case 3: return "Exact Match";
case 4: return "Regular Expression";
case 5: return "Fuzzy Match";
default:
console.error(`unknown matching algorithm '${data}'`);
return sanitise(data);
}
}
},
{
title: "Match",
data: "match"
},
{
title: "Case Sensitivity",
data: "is_insensitive",
render: function(data) {
return data ? "Insensitive" : "Sensitive"
}
},
{
title: "Control List",
data: "is_whitelist",
render: function(data) {
return data ? "Whitelist" : "Blacklist";
}
}
]
});
bindTableCheckboxes("#filtersTable", filtersTable, "#filtersTabPane .table-del-btn");
}
$("#addFilterBtn").on("click", async function() {
await showEditFilterModal(-1);
})
async function showEditFilterModal(filterId) {
if (filterId === -1) {
$("#filterFormModal input, #filterFormModal textarea").val("");
$("#filterFormModal input:checkbox").prop("checked", false);
$("#filterAlgorithm").val("").change();
$("#filterFormModal .form-create").show();
$("#filterFormModal .form-edit").hide();
}
else {
const filter = filtersTable.row(function(idx, data, node) {
return data.id === filterId;
}).data();
$("#filterAlgorithm").val("").change();
$("#filterAlgorithm").val(filter.matching_algorithm).change();
$("#filterName").val(filter.name);
$("#filterMatch").val(filter.match);
$("#filterWhitelist").prop("checked", filter.is_whitelist);
$("#filterInsensitive").prop("checked", filter.is_insensitive);
$("#filterFormModal .form-create").hide();
$("#filterFormModal .form-edit").show();
}
$("#filterId").val(filterId);
$("#filterFormModal").modal("show");
}
$("#filterForm").on("submit", async function(event) {
event.preventDefault();
var id = $("#filterId").val();
name = $("#filterName").val();
algorithm = $("#filterAlgorithm option:selected").val();
match = $("#filterMatch").val();
isWhitelist = $("#filterWhitelist").prop("checked");
isInsensitive = $("#filterInsensitive").prop("checked");
guildId = getCurrentlyActiveServer().guild_id;
var filterPrimaryKey = await saveFilter(id, name, algorithm, match, isWhitelist, isInsensitive, guildId);
if (filterPrimaryKey) {
showToast("success", "Filter Saved", "Filter ID " + filterPrimaryKey);
await loadFilters(guildId);
await loadFilterOptions(guildId);
}
$("#filterFormModal").modal("hide");
});
async function saveFilter(id, name, algorithm, match, isWhitelist, isInsensitive, guildId) {
var formData = new FormData();
formData.append("name", name);
formData.append("matching_algorithm", algorithm);
formData.append("match", match);
formData.append("is_whitelist", isWhitelist);
formData.append("is_insensitive", isInsensitive);
formData.append("guild_id", guildId);
var response;
try {
if (id === "-1") response = await newFilter(formData);
else response = await editFilter(id, formData);
}
catch (err) {
showToast("danger", "Filter Error", err.responseText, 18000);
return false
}
return response.id;
}
function clearExistingFilterRows() {
$("#filtersTable thead .table-select-all").prop("checked", false).prop("indeterminate", false)
filtersTable.clear().draw(false)
}
$("#filtersTabPane").on("click", ".table-refresh-btn", async function() {
loadFilters(getCurrentlyActiveServer().guild_id);
});
async function loadFilters(guildId) {
if (!guildId)
return;
setTableFilter("filtersTable", "guild_id", guildId);
ensureTablePagination("filtersTable");
$("#filtersTabPane .table-del-btn").prop("disabled", true);
clearExistingFilterRows();
try {
var contentFilters = await getFilters(tableFilters["filtersTable"], tableSorts["filtersTable"]);
filtersTable.rows.add(contentFilters.results).draw(false);
}
catch (err) {
console.error(err)
showToast("danger", `Error Loading Filters: HTTP ${err.status}`, err, 15000);
return;
}
updateTableContainer(
"filtersTabPane",
tableFilters["filtersTable"]["page"],
tableFilters["filtersTable"]["page_size"],
contentFilters.results.length,
contentFilters.count,
contentFilters.next,
contentFilters.previous
);
$("#filtersTable thead .table-select-all").prop("disabled", contentFilters.results.length === 0);
console.debug(`loaded filters, ${contentFilters.results.length} found`);
}
$(document).ready(async function() {
await loadMatchingAlgorithms();
});
async function loadMatchingAlgorithms() {
// Disable input while options are loading
$("#filterAlgorithm").prop("disabled", true);
// Delete existing options
$("#filterAlgorithm option").each(function() {
if ($(this).val())
$(this).remove();
});
// Clear select2 input
$("#filterAlgorithm").val("").change();
try {
options = await getFilterOptions();
options.actions.POST.matching_algorithm.choices.forEach(algorithm => {
$("#filterAlgorithm").append($("<option>", {
text: algorithm.display_name,
value: algorithm.value > 0 ? algorithm.value : "" // empty string for 'None' option at 0
})); // (helps with validation)
});
}
catch (error) {
console.error(error);
}
finally {
// Re-enable the input
$("#filterAlgorithm").prop("disabled", false);
}
};
$(document).on("selectedServerChange", async function() {
const activeServer = getCurrentlyActiveServer();
await loadFilters(activeServer.guild_id);
});
// #region Delete Filters
$("#deleteEditFilter").on("click", async function() {
const filterId = parseInt($("#filterId").val());
const filter = filtersTable.row(function(idx, row) { return row.id === filterId }).data();
const filterName = sanitise(filter.name);
$("#filterFormModal").modal("hide");
await confirmationModal(
"Delete a Filter",
`Do you wish to permanently delete <b>${filterName}</b>?`,
"danger",
async () => {
await deleteFilter(filterId);
await loadFilters(getCurrentlyActiveServer().guild_id);
showToast(
"danger",
"Deleted a Filter",
filterName,
12000
);
},
async () => {
$("#filterFormModal").modal("show");
}
);
});
async function deleteSelectedFilters() {
const rows = filtersTable.rows(".selected").data().toArray();
const names = rows.map(row => row.name);
const namesString = arrayToHtmlList(names, true).prop("outerHTML");
const isMany = names.length > 1;
await confirmationModal(
`Delete ${isMany ? "Many Filters" : "a Filter"}`,
`Do you wish to permanently delete ${isMany ? "these" : "this"} <b>${names.length}</b> filter${isMany ? "s" : ""}?<br><br>${namesString}`,
"danger",
async () => {
rows.forEach(async row => { await deleteFilter(row.id) });
showToast(
"danger",
`Delete ${names.length} Subscription${isMany ? "s" : ""}`,
`${arrayToHtmlList(names, false).prop("outerHTML")}`,
12000
);
// Multi-deletion can take time, this timeout ensures the refresh is accurate
setTimeout(async () => {
await loadFilters(getCurrentlyActiveServer().guild_id);
}, 600);
},
null
);
}
// #endregion

View File

@ -1,313 +0,0 @@
// #region Loaded Servers
var loadedServers = {};
// Returns the currently active server, or null if none are active.
function getCurrentlyActiveServer() {
const activeServerAndId = Object.entries(loadedServers).find(([id, server]) => server.currentlyActive);
if (activeServerAndId === undefined)
return null;
var [id, activeServer] = activeServerAndId;
activeServer.id = id;
return activeServer;
}
// Returns the requested server from the provided snowflake id
function getServerFromSnowflake(guildId) {
const serverAndId = Object.entries(loadedServers).find(([id, server]) => server.guild_id == guildId);
if (serverAndId === undefined)
return null;
var [id, server] = serverAndId;
server.id = id;
return server;
}
function addToLoadedServers(server, selectNew=true) {
// Remove the 'id' property and add the 'currentlyActive' property
({id, ...rest} = server, server = {...rest, currentlyActive: false})
// Save the server as loaded
loadedServers[id] = server;
// Display the loaded server
addServerTemplate(id, sanitise(server.guild_id), sanitise(server.name), sanitise(server.icon), sanitise(server.permissions), sanitise(server.owner));
// Select the newly added server
if (selectNew) {
selectServer(id);
}
}
function removeFromLoadedServers(serverPrimaryKey) {
delete loadedServers[serverPrimaryKey];
removeServerTemplate(serverPrimaryKey);
$("#backToSelectServer").click();
}
// #endregion
// #region Server Back Btn
$("#backToSelectServer").on("click", function() {
$("#noSelectedServer").show();
$("#selectedServerContainer").hide();
});
// #endregion
// #region Server Modal
$("#serverOptionsRefreshBtn").on("click", async function() {
await loadServerOptions();
});
// Load server options into the 'Add Server' dropdown
async function loadServerOptions() {
// Disable controls while loading
$("#serverOptions").prop("disabled", true);
$("#serverOptionsRefreshBtn").prop("disabled", true).find("i.bi").addClass("spinning-360");
// Remove existing options
$("#serverOptions option").each(function() {
if ($(this).val()) {
$(this).remove();
}
});
// Deselect any selected option
$("#serverOptions").val(null).trigger("change");
// Fetch and append the server options
try {
const servers = await loadGuilds();
servers.forEach(server => {
$("#serverOptions").append($("<option>", {
value: server.id,
text: sanitise(server.name),
"data-icon": sanitise(server.icon),
"data-permissions": sanitise(server.permissions),
"data-isowner": sanitise(server.owner)
}));
});
}
catch (error) {
console.error(JSON.stringify(error, null, 4));
showToast("danger", `Error Loading Guilds: HTTP ${error.status}`, error.responseJSON.message, 15000);
}
finally {
// Re-enable controls
$("#serverOptions").prop("disabled", false);
$("#serverOptionsRefreshBtn").prop("disabled", false).find("i.bi").removeClass("spinning-360");
}
}
// #endregion
// #region Server Sidebar
// Load any existing 'saved guilds' from the database
async function loadSavedGuilds() {
try {
const response = await getSavedGuilds();
response.forEach(server => {
// 'Register' the server, by storing it for later and
// displaying it on the server list sidebar
addToLoadedServers(server, false);
});
}
catch (error) {
alert("Error loading saved guilds: " + error);
}
}
// Create an element for the added server and show it
function addServerTemplate(serverPrimaryKey, serverGuildId, serverName, serverIconHash, serverPermissions, serverIsOwner) {
template = $($("#serverItemTemplate").html());
template.find("img").attr("src", `https://cdn.discordapp.com/icons/${serverGuildId}/${serverIconHash}.webp?size=80`);
template.attr("data-id", serverPrimaryKey);
// Tooltips
template.attr("data-bs-title", serverName);
template.tooltip();
// Bind the button for selecting this server
template.find(".server-item-selector").off("click").on("click", function() {
selectServer(serverPrimaryKey);
});
$("#serverList").prepend(template);
}
function removeServerTemplate(serverPrimaryKey) {
$(`#serverList .server-item[data-id=${serverPrimaryKey}]`).remove();
}
// Open 'Add Server' Form Modal
$("#newServerBtn").on("click", function() {
newServerModal();
});
function newServerModal() {
$("#serverFormModal").modal("show");
}
// #endregion
// #region New Server
// Submit 'Add Server' Form
$("#serverForm").on("submit", async function(event) {
event.preventDefault();
var selectedOption = $("#serverOptions option:selected");
serverName = selectedOption.text();
serverGuildId = selectedOption.val();
serverIconHash = selectedOption.attr("data-icon");
serverPermissions = selectedOption.attr("data-permissions");
serverIsOwner = selectedOption.attr("data-isowner");
var serverPrimaryKey = await registerNewServer(serverName, serverGuildId, serverIconHash, serverPermissions, serverIsOwner);
if (serverPrimaryKey)
addToLoadedServers(await getSavedGuild(serverPrimaryKey));
$("#serverFormModal").modal("hide");
});
// Add a new 'saved guild' based on the info provided
// returns `response.id` if successful, else false
async function registerNewServer(serverName, serverGuildId, serverIconHash, serverPermissions, serverIsOwner) {
var formData = new FormData();
formData.append("name", serverName);
formData.append("guild_id", serverGuildId);
formData.append("icon", serverIconHash);
formData.append("added_by", currentUserId);
formData.append("permissions", serverPermissions);
formData.append("owner", serverIsOwner === "true");
try { response = await newSavedGuild(formData); }
catch (err) {
if (err.status === 409)
showToast("warning", "Server Conflict", `Can't add ${sanitise(serverName)} because it already exists.`, 10000);
else
console.error(JSON.stringify(err, null, 4));
return false;
}
return response.id;
}
// #endregion
// #region Select Server
function selectServer(primaryKey) {
var server = loadedServers[primaryKey];
// Change appearance of selected vs none-selected items
$("#serverList .server-item").removeClass("active")
$(`#serverList .server-item[data-id=${primaryKey}]`).addClass("active")
// Display details of the selected server
$("#selectedServerContainer .selected-server-name").text(sanitise(server.name));
$("#selectedServerContainer .selected-server-id").text(sanitise(server.guild_id));
$("#selectedServerContainer .selected-server-icon").attr("src", `https://cdn.discordapp.com/icons/${server.guild_id}/${server.icon}.webp?size=80`);
// Disable all loaded servers
$.each(loadedServers, function(serverPrimaryKey, server) {
server.currentlyActive = false;
});
// Activate current selected server
loadedServers[primaryKey].currentlyActive = true;
$("#noSelectedServer").hide();
$("#selectedServerContainer").show().css("display", "flex");
$(document).trigger("selectedServerChange");
}
// #endregion
// #region Delete Server Btn
$("#deleteSelectedServerBtn").on("click", async function() {
const notes = [
"No Subscriptions, Filters or Tracked Content will be deleted.",
"No data will be deleted for other users.",
"The server will no longer appear on your sidebar.",
"You can re-add the server",
"All Subscriptions, Filters and Tracked Content will be available when/if you re-add the server."
];
const notesString = arrayToHtmlList(notes).prop("outerHTML");
await confirmationModal(
"Close this server?",
`This is a safe, non-permanent action:<br><br>${notesString}`,
"warning",
deleteSelectedServer,
null
);
});
async function deleteSelectedServer() {
var activeServer = getCurrentlyActiveServer();
if (!activeServer) {
showToast("danger", "Error Deleting Server", "You must select a server to delete.");
return;
}
console.debug(`Deleting ${activeServer.id}: ${JSON.stringify(activeServer, null, 4)}`)
try {
await deleteSavedGuild(activeServer.id);
removeFromLoadedServers(activeServer.id);
}
catch (error) {
alert(error)
alert(JSON.stringify(error, null, 4))
}
};
// #endregion
$(document).on("selectedServerChange", function() {
resolveServerStrings();
$("#serverJoinAlert").hide();
})
// #region Resolve Strings
function resolveServerStrings() {
const server = getCurrentlyActiveServer();
// Server names
$(".resolve-to-server-name").text(sanitise(server.name));
// Server Guild Ids
$(".resolve-to-server-id").text(sanitise(server.guild_id))
// Bot Invite links
$(".resolve-to-invite-link").attr("href", `https://discord.com/oauth2/authorize
?client_id=${discordClientId}
&permissions=2147534848
&scope=bot+applications.commands
&guild_id=${sanitise(server.guild_id)}
&disable_guild_select=true`);
}
// #endregion

View File

@ -1,59 +0,0 @@
$("#serverSettingsBtn").on("click", async function() {
await showServerSettingsModal();
});
async function showServerSettingsModal() {
const server = getCurrentlyActiveServer();
var guildSettings;
try { guildSettings = (await getGuildSettings(server.guild_id)).results[0] }
catch (error) {
console.error(error)
return;
}
$("#guildSettingsId").val(guildSettings.id);
$("#guildSettingsGuildId").val(guildSettings.guild_id);
$("#guildSettingsActive").prop("checked", guildSettings.active);
updateColourInput("guildSettingsDefaultEmbedColour", guildSettings.default_embed_colour);
$("#serverSettingsModal").modal("show");
}
$("#serverSettingsForm").on("submit", async function(e) {
e.preventDefault();
var id = $("#guildSettingsId").val();
guildId = $("#guildSettingsGuildId").val();
active = $("#guildSettingsActive").prop("checked");
defaultEmbedColour = getColourInputVal("guildSettingsDefaultEmbedColour", false);
const pk = await saveGuildSettings(id, guildId, defaultEmbedColour, active);
if (pk) {
showToast("success", "Server Settings Saved", "Primary Key: " + pk);
}
updateDefaultSubEmbedColour();
$("#serverSettingsModal").modal("hide");
})
async function saveGuildSettings(id, guildId, defaultEmbedColour, active) {
var formData = new FormData();
formData.append("guild_id", guildId);
formData.append("default_embed_colour", defaultEmbedColour);
formData.append("active", active);
var response;
try {
response = await editGuildSettings(id, formData);
}
catch (err) {
console.error(err);
return false;
}
return response.id;
}

View File

@ -1,633 +0,0 @@
var subTable = null;
discordChannels = [];
subSearchTimeout = null;
subOptions = null;
// Create subscription table
async function initSubscriptionTable() {
subOptions = await getSubscriptionOptions();
await initTable("#subscriptionsTabPane", "subTable", loadSubscriptions, showEditSubModal, deleteSelectedSubscriptions, subOptions);
subTable = $("#subTable").DataTable({
info: false,
paging: false,
ordering: false,
searching: false,
autoWidth: false,
order: [],
select: {
style: "multi+shift",
selector: 'th:first-child input[type="checkbox"]'
},
columnDefs: [
{ orderable: false, targets: "no-sort" },
{
targets: 0,
checkboxes: { selectRow: true }
},
],
columns: [
{
// Select row checkbox column
title: '<input type="checkbox" class="form-check-input table-select-all" />',
data: null,
orderable: false,
className: "text-center col-switch-width",
render: function() {
return '<input type="checkbox" class="form-check-input table-select-row" />'
}
},
{ title: "ID", data: "id", visible: false },
{
title: "Name",
data: "name",
className: "text-truncate",
render: function(data, type, row) {
const name = sanitise(data);
return `<button type="button" onclick="showEditSubModal(${row.id})" class="btn btn-link text-start text-decoration-none">${name}</button>`;
}
},
{
title: "URL",
data: "url",
className: "text-truncate",
render: function(data, type) {
const url = sanitise(data);
return `<a href="${url}" class="btn btn-link text-start text-decoration-none" target="_blank">${url}</a>`;
}
},
{
title: "Channels",
data: "channels_count",
className: "text-center",
render: function(data) {
const channelsCount = sanitise(data);
return `<span class="badge text-bg-secondary">${channelsCount}</span>`;
}
},
{
title: "Created",
data: "creation_datetime",
render: function(data, type) {
let dateTime = new Date(data);
return $(`
<span data-bs-trigger="hover focus"
data-bs-html="true"
data-bs-custom-class="text-center"
data-bs-toggle="popover"
data-bs-content="${formatStringDate(dateTime, "%a, %D %B, %Y<br>%H:%M:%S")}">
${formatStringDate(dateTime, "%D, %b %Y")}
</span>
`).popover()[0];
}
},
{
title: "Notes",
data: "extra_notes",
orderable: false,
className: "text-center",
render: function(data, type) {
if (!data) { return "" }
const extraNotes = sanitise(data);
return $(`
<i class="bi bi-chat-left-text"
data-bs-trigger="hover focus"
data-bs-toggle="popover"
data-bs-title="Extra Notes"
data-bs-content="${extraNotes}">
</i>
`).popover()[0];
}
},
{
title: "Active",
data: "active",
orderable: false,
className: "text-center form-switch",
render: function(data, type) {
return `<input type="checkbox" class="sub-toggle-active form-check-input ms-0" ${data ? "checked" : ""} />`
}
},
{
orderable: false,
className: "p-0",
render: function(data, type, row) {
const embedColour = sanitise(row.embed_colour);
return `<div class="h-100" style="background-color: #${embedColour}; width: .25rem;">&nbsp;</div>`
}
}
]
});
bindTableCheckboxes("#subTable", subTable, "#subscriptionsTabPane .table-del-btn");
}
async function updateSubFromObject(sub, handleErrorMsg=true) {
let data = {
"name": sub.name,
"url": sub.url,
"guild_id": sub.guild_id,
"extra_notes": sub.extra_notes,
"embed_colour": sub.embed_colour,
"article_fetch_image": sub.article_fetch_image,
"published_threshold": sub.published_threshold,
"active": sub.active
};
let formData = new FormData();
for (key in data) {
formData.append(key, data[key]);
}
sub.article_title_mutators.forEach(mutator => formData.append("article_title_mutators", mutator.id));
sub.article_desc_mutators.forEach(mutator => formData.append("article_desc_mutators", mutator.id));
sub.filters.forEach(filter => formData.append("filters", filter));
return await saveSubscription(sub.id, formData, handleErrorMsg=handleErrorMsg);
}
$("#subscriptionsTabPane").on("change", ".sub-toggle-active", async function () {
/*
Lock all toggles to soft-prevent spam.
There is a rate limit, but allowing the user to
reach it from this toggle would be bad.
*/
$(".sub-toggle-active").prop("disabled", true);
try {
const active = $(this).prop("checked");
const sub = subTable.row($(this).closest("tr")).data();
// Update the table row
sub.active = active;
subTable.data(sub).draw();
// Update the database
const subId = await updateSubFromObject(sub, handleErrorMsg=false);
if (!subId) {
throw Error("This subscription no longer exists.");
}
showToast(
active ? "success" : "danger",
"Subscription " + (active ? "Activated" : "Deactivated"),
"Subscription ID: " + subId
);
}
catch (error) {
console.error(error);
showToast(
"danger",
"Error Updating Subscription",
`Tried to toggle activeness, but encountered a problem. <br><code>${error}</code>`
);
}
finally {
// Re-enable toggles after 500ms
setTimeout(() => {
$(".sub-toggle-active").prop("disabled", false); },
500
);
}
});
// Open new subscription modal
$("#addSubscriptionBtn").on("click", async function() {
await showEditSubModal(-1);
});
async function showEditSubModal(subId) {
if (subId === -1) {
$("#subFormModal .form-create, #subAdvancedModal .form-create").show();
$("#subFormModal .form-edit, #subAdvancedModal .form-edit").hide();
$("#subFormModal input, #subFormModal textarea").val("");
$("#subChannels").val("").change();
$("#subFilters").val("").change();
$("#subTitleMutators").val("").change();
$("#subDescMutators").val("").change();
$("#subActive").prop("checked", true);
$("#subEmbedColour .colour-reset").click();
$("#subArticleFetchImage").prop("checked", true);
$("#subPubThreshold").val(getCurrentDateTime());
}
else {
$("#subFormModal .form-create, #subAdvancedModal .form-create").hide();
$("#subFormModal .form-edit, #subAdvancedModal .form-edit").show();
const subscription = subTable.row(function(idx, data, node) {
return data.id === subId;
}).data();
$("#subName").val(subscription.name);
$("#subUrl").val(subscription.url);
$("#subExtraNotes").val(subscription.extra_notes);
$("#subActive").prop("checked", subscription.active);
$("#subTitleMutators").val("").change();
$("#subTitleMutators").val(subscription.article_title_mutators.map(mutator => mutator.id)).change();
$("#subDescMutators").val("").change();
$("#subDescMutators").val(subscription.article_desc_mutators.map(mutator => mutator.id)).change();
const channels = await getSubChannels(subscription.id);
$("#subChannels").val("").change();
$("#subChannels").val(channels.results.map(channel => channel.channel_id)).change();
$("#subFilters").val("").change();
$("#subFilters").val(subscription.filters).change();
updateColourInput("subEmbedColour", `#${subscription.embed_colour}`);
$("#subArticleFetchImage").prop("checked", subscription.article_fetch_image);
$("#subPubThreshold").val(subscription.published_threshold.split('+')[0]);
}
$("#subId").val(subId);
$("#subFormModal").modal("show");
}
function getValueFromField(elem) {
const tagName = elem.tagName.toLowerCase();
const $elem = $(elem);
if (tagName) { return $elem.val() }
switch ($elem.attr("type")) {
case "checkbox":
return $elem.prop("checked");
default:
return $elem.val();
}
}
$("#subForm").on("submit", async function(event) {
event.preventDefault();
let subId = $("#subId").val();
let guildId = getCurrentlyActiveServer().guild_id;
// TODO: move this into a function, so I can fix the active toggle switches which are broken due to this change
let formData = new FormData();
formData.append("guild_id", guildId);
// Populate formdata with [data-field] control values
$('#subForm [data-field], #subAdvancedModal [data-field]').each(function() {
const value = getValueFromField(this);
formData.append($(this).data("field"), value);
});
// Add title mutators to formdata
$("#subTitleMutators option:selected").toArray().map(mutator => parseInt(mutator.value)).forEach(
mutator => formData.append("article_title_mutators", mutator)
);
// Add description mutator to formdata
$("#subDescMutators option:selected").toArray().map(mutator => parseInt(mutator.value)).forEach(
mutator => formData.append("article_desc_mutators", mutator)
);
// Add Filters to formdata
$("#subFilters option:selected").toArray().forEach(
filter => formData.append("filters", parseInt(filter.value))
);
// This field is constructed differently, so needs to be specifically added
formData.append("embed_colour", getColourInputVal("subEmbedColour", false));
subId = await saveSubscription(subId, formData);
if (subId) {
showToast("success", "Subscription Saved", `Subscription ID ${subId}`);
}
else {
showToast("danger", "Error Saving Subscription", "");
return;
}
await deleteSubChannels(subId);
$("#subChannels option:selected").each(async function() {
let $channel = $(this);
let channelFormData = new FormData();
channelFormData.append("channel_id", $channel.val());
channelFormData.append("channel_name", $channel.data("name"));
channelFormData.append("subscription", subId);
await newSubChannel(channelFormData);
});
await loadSubscriptions(guildId);
$("#subFormModal").modal("hide");
});
async function saveSubscription(id, formData, handleErrorMsg=true) {
let response
try {
response = id === "-1" ? await newSubscription(formData) : await editSubscription(id, formData);
}
catch (err) {
console.error(err);
if (handleErrorMsg) {
showToast("danger", "Subscription Error", err.responseText, 18000);
}
return false;
}
return response.id;
}
async function saveSubChannel(formData) {
var response
try {
response = await newSubChannel(formData);
}
catch (error) {
console.log(error);
showToast("danger", "Failed to save subchannel", error, 18000);
return false
}
return response.id
}
function clearExistingSubRows() {
$("#subTable thead .table-select-all").prop("checked", false).prop("indeterminate", false);
subTable.clear().draw(false);
}
$("#subscriptionsTabPane").on("click", ".table-refresh-btn", async function() {
loadSubscriptions(getCurrentlyActiveServer().guild_id);
});
async function loadSubscriptions(guildId) {
if (!guildId)
return;
setTableFilter("subTable", "guild_id", guildId);
ensureTablePagination("subTable");
$("#subscriptionsTabPane .table-del-btn").prop("disabled", true);
clearExistingSubRows();
try {
var subs = await getSubscriptions(tableFilters["subTable"], tableSorts["subTable"]);
subTable.rows.add(subs.results).draw(false);
}
catch (err) {
console.error(err)
showToast("danger", `Error Loading Subscriptions: HTTP ${err.status}`, err, 15000);
return;
}
updateTableContainer(
"subscriptionsTabPane",
tableFilters["subTable"]["page"],
tableFilters["subTable"]["page_size"],
subs.results.length,
subs.count,
subs.next,
subs.previous
);
$("#subTable thead .table-select-all").prop("disabled", subs.results.length === 0);
console.debug("loading subs, " + subs.results.length + " found");
}
// #region Server Change Event Handler
$(document).on("selectedServerChange", async function() {
let server = getCurrentlyActiveServer();
guildId = server.guild_id;
await updateDefaultSubEmbedColour();
await loadSubscriptions(guildId);
await loadChannelOptions(guildId);
await loadFilterOptions(guildId);
await loadMutatorOptions();
})
async function updateDefaultSubEmbedColour(settings=null) {
if (!settings){
settings = (await getGuildSettings(guildId)).results[0]
}
$("#subEmbedColour .colour-reset").attr("data-defaultcolour", "#" + settings.default_embed_colour);
}
// #endregion
// #region Delete Subscriptions
// Delete button on the 'edit subscription' modal
$("#deleteEditSub").on("click", async function() {
const subId = parseInt($("#subId").val());
const sub = subTable.row(function(idx, row) { return row.id === subId }).data();
const subName = sanitise(sub.name);
$("#subFormModal").modal("hide");
await confirmationModal(
"Delete a Subscription",
`Do you wish to permanently delete <b>${subName}</b>?`,
"danger",
async () => {
await deleteSubscription(subId);
await loadSubscriptions(getCurrentlyActiveServer().guild_id);
showToast(
"danger",
"Deleted a Subscription",
subName,
12000
);
},
async () => {
$("#subFormModal").modal("show");
}
);
});
async function deleteSelectedSubscriptions() {
const rows = subTable.rows(".selected").data().toArray();
const names = rows.map(row => row.name);
const namesString = arrayToHtmlList(names, true).prop("outerHTML");
const isMany = names.length > 1;
await confirmationModal(
`Delete ${isMany ? "Many Subscriptions" : "a Subscription"}`,
`Do you wish to permanently delete ${isMany ? "these" : "this"} <b>${names.length}</b> subscription${isMany ? "s" : ""}?<br><br>${namesString}`,
"danger",
async () => {
rows.forEach(async row => { await deleteSubscription(row.id) });
showToast(
"danger",
`Deleted ${names.length} Subscription${isMany ? "s" : ""}`,
`${arrayToHtmlList(names, false).prop("outerHTML")}`,
12000
);
// Multi-deletion can take time, this timeout ensures the refresh is accurate
setTimeout(async () => {
await loadSubscriptions(getCurrentlyActiveServer().guild_id);
}, 600);
},
null
);
}
// #endregion
// #region Load Modal Options
async function loadChannelOptions(guildId) {
// Disable input while options are loading
$("#subChannels").prop("disabled", true);
// Delete existing options
$("#subChannels option").each(function() {
if ($(this).val())
$(this).remove();
});
// Clear select2 input
$("#subChannels").val("").change();
try {
const channels = await loadChannels(guildId);
// If we have reached the discord API rate limit
if (channels.message && channels.message.includes("rate limit")) {
throw new Error(
`${channels.message} Retry after ${channels.retry_after} seconds.`
)
}
// If we can't fetch channels due to error
if (channels.code === 50001) {
// Also check that the user hasn't changed the currently active guild, otherwise
// the alert will show under the wrong server.
if (getCurrentlyActiveServer().guild_id === guildId)
$("#serverJoinAlert").show();
const guildName = sanitise(getServerFromSnowflake(guildId).name);
throw new Error(
`Unable to retrieve channels from Guild <b>${guildName}</b>.
Ensure that @PYRSS is a member with permissions
to view channels.`
);
}
// Sort by the specified position of each channel object
channels.sort((a, b) => a.position - b.position);
discordChannels = [];
channels.forEach(channel => {
// We only want TextChannels, which have a type of 0
if (channel.type !== 0)
return;
let channelObj = {text: `#${channel.name}`, value: channel.id, "data-name": channel.name}
$("#subChannels").append($("<option>", channelObj));
discordChannels.push(channelObj);
});
}
catch(error) {
console.error(error);
showToast("danger", "Error loading channels", error, 18000);
}
finally {
// Re-enable the input
$("#subChannels").prop("disabled", false);
}
}
async function loadMutatorOptions() {
// Disable input while options are loading
$(".sub-mutators-field").prop("disabled", true);
// Delete existing options
$(".sub-mutators-field option").each(function() {
if ($(this).val())
$(this).remove();
});
// Clear select2 input
$(".sub-mutators-field").val("").change();
try {
const mutators = await getMutators();
console.log(JSON.stringify(mutators));
mutators.forEach(mutator => {
$(".sub-mutators-field").append($("<option>", {
text: mutator.name,
value: mutator.id
}));
});
}
catch(error) {
console.error(error);
showToast("danger", "Error loading sub mutators", error, 18000);
}
finally {
// Re-enable the input
$(".sub-mutators-field").prop("disabled", false);
}
}
async function loadFilterOptions(guildId) {
// Disable input while options are loading
$("#subFilters").prop("disabled", true);
// Delete existing options
$("#subFilters option").each(function() {
if ($(this).val())
$(this).remove();
});
// Clear select2 input
$("#subFilters").val("").change();
try {
const filters = await getFilters({guild_id: guildId});
console.log(JSON.stringify(filters));
filters.results.forEach(filter => {
$("#subFilters").append($("<option>", {
text: filter.name,
value: filter.id
}));
});
}
catch(error) {
console.error(error);
showToast("danger", "Error loading sub filters", error, 18000);
}
finally {
// Re-enable the input
$("#subFilters").prop("disabled", false);
}
}
// #endregion

View File

@ -1,27 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %} Blank Page {% endblock title %}
<!-- Specific CSS goes HERE -->
{% block stylesheets %}
<link rel="stylesheet" href="{% static 'css/home/main.css' %}">
<link rel="stylesheet" href="{% static '/css/select2-bootstrap.min.css' %}">
{% endblock stylesheets %}
{% block content %}
<!-- ### $App Screen Content ### -->
<main class='main-content bg-body-tertiary'>
<div id='mainContent'>
<div class="full-container">
<h1>Add content here</h1>
</div>
</div>
</main>
{% endblock content %}
<!-- Specific Page JS goes HERE -->
{% block javascripts %}{% endblock javascripts %}

View File

@ -1,16 +0,0 @@
<div id="confirmationModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<div class="modal-header">
<h5 class="modal-title mx-2"></h5>
</div>
<div class="modal-body p-4">
<p class="mb-0"></p>
</div>
<div class="modal-footer px-4">
<button type="button" class="btn rounded-1 modal-confirm-btn" tabindex="1">Confirm</button>
<button type="button" class="btn btn-secondary rounded-1 ms-3 ms-0 modal-dismiss-btn" tabindex="2">Cancel</button>
</div>
</div>
</div>
</div>

View File

@ -1,61 +0,0 @@
<div id="filterFormModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<form id="filterForm" class="mb-0" novalidate>
<div class="modal-header">
<h5 class="modal-title ms-2">
<span class="form-create">Add</span>
<span class="form-edit">Edit</span>
Filter
</h5>
</div>
<div class="modal-body p-4">
<input type="hidden" id="filterId" name="filterId">
<div class="row">
<div class="col-12">
<div class="mb-4">
<label for="filterName" class="form-label">Name</label>
<input type="text" id="filterName" name="filterName" class="form-control rounded-1" tabindex="1">
</div>
</div>
<div class="col-12">
<div class="mb-4">
<label for="filterAlgorithm" class="form-label">Matching Algorithm</label>
<select name="filterAlgorithm" id="filterAlgorithm" class="select-2" data-dropdownparent="#filterFormModal" tabindex="2"></select>
</div>
</div>
<div class="col-12">
<div class="mb-4">
<label for="filterMatch" class="form-label">Matching Pattern</label>
<input type="text" id="filterMatch" name="filterMatch" class="form-control rounded-1" placeholder="" tabindex="3">
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="form-switch ps-0">
<label for="filterWhitelist" class="form-check-label mb-2">Is Whitelist?</label>
<br>
<input type="checkbox" id="filterWhitelist" name="filterWhitelist" class="form-check-input ms-0 mt-0" tabindex="4">
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="form-switch ps-0">
<label for="filterInsensitive" class="form-check-label mb-2">Case Insensitive?</label>
<br>
<input type="checkbox" id="filterInsensitive" name="filterInsensitive" class="form-check-input ms-0 mt-0" tabindex="5">
</div>
</div>
</div>
</div>
<div class="modal-footer px-4">
<button type="button" id="deleteEditFilter" class="btn btn-danger rounded-1 me-auto ms-0 form-edit" tabindex="6">Delete</button>
<button type="submit" class="btn btn-primary rounded-1" tabindex="7">
<span class="form-create">Create</span>
<span class="form-edit">Confirm Edit</span>
</button>
<button type="button" class="btn btn-secondary rounded-1 ms-3 me-0" data-bs-dismiss="modal" tabindex="8">Cancel</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -1,34 +0,0 @@
<div id="serverFormModal" class="modal fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<form id="serverForm" class="mb-0" novalidate>
<div class="modal-header">
<h5 class="modal-title ms-2">
Add Server
</h5>
</div>
<div class="modal-body p-4">
<div class="d-flex flex-nowrap mb-3">
<div class="flex-fill">
<select name="serverOptions" id="serverOptions" class="select-2 rounded-1" data-dropdownparent="#serverFormModal">
<option value="">-- Select a Server --</option>
</select>
</div>
<button type="button" id="serverOptionsRefreshBtn" class="btn btn-secondary rounded-1 ms-3">
<i class="bi bi-arrow-clockwise d-block"></i>
</button>
</div>
<p class="mb-0 form-text">
<b>Not seeing your server?</b>
Ensure that you are authenticated as either the owner or an administrator of the server you wish to add.
</p>
</div>
<div class="modal-footer px-4">
<button type="submit" class="btn btn-primary rounded-1 me-0">Submit</button>
<button type="button" class="btn btn-secondary rounded-1 ms-3" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -1,30 +0,0 @@
<form id="serverSettingsForm" novalidate>
<div class="row my-3 px-3">
<div class="col-12 text-end">
<button type="submit" id="saveSettings" class="btn btn-primary rounded-1">Save Changes</button>
<button type="button" class="btn btn-outline-danger rounded-1 ms-3">Reset All</button>
</div>
</div>
<div class="row my-3 px-3">
<div class="col-lg-4">
<div class="mb-4">
<div class="colour-input"
data-id="defaultEmbedColour"
data-label="Default Embed Colour"
data-helptext="Default colour of each embed in Discord."
data-defaultcolour="#3498db">
</div>
</div>
</div>
<div class="col-lg-8"></div>
<div class="col-lg-4">
<div class="form-switch ps-0">
<label for="serverActive" class="form-check-label mb-2">Server Active?</label>
<br>
<input type="checkbox" name="serverActive" id="serverActive" class="form-check-input ms-0 mt-0">
<br>
<div class="form-text">Is this server active?</div>
</div>
</div>
</div>
</form>

View File

@ -1,153 +0,0 @@
<div id="subFormModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<form id="subForm" class="mb-0" novalidate>
<div class="modal-header">
<h5 class="modal-title ms-2">
<span class="form-create">Add</span>
<span class="form-edit">Edit</span>
Subscription
</h5>
</div>
<div class="modal-body p-4">
<input type="hidden" id="subId" name="subId" data-role="is-id">
<div class="row">
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<label for="subName" class="form-label">Name</label>
<input type="text" id="subName" name="subName" class="form-control rounded-1" placeholder="My News Feed" data-field="name" tabindex="1">
<div class="form-text">Use a unique name to refer to this subscription.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<label for="subUrl" class="form-label">URL</label>
<input type="url" id="subUrl" name="subUrl" class="form-control rounded-1" placeholder="http://example.com/rss.xml" data-field="url" tabindex="2">
<div class="form-text">Must point to a valid <a href="https://en.wikipedia.org/wiki/RSS" class="text-decoration-none" target="_blank">RSS</a> feed.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<label for="subChannels" class="form-label">Channels</label>
<select name="subChannels" id="subChannels" class="select-2" multiple data-dropdownparent="#subFormModal" tabindex="3"></select>
<div class="form-text">Subscription content will be sent to these channels.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<label for="subFilters" class="form-label">Filters</label>
<select name="subFilters" id="subFilters" class="select-2" multiple data-dropdownparent="#subFormModal" tabindex="4"></select>
<div class="form-text">Filters to apply to this subscription's content.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="mb-4 mb-lg-0">
<label for="subExtraNotes" class="form-label">Extra Notes</label>
<textarea id="subExtraNotes" name="subExtraNotes" class="form-control rounded-1" placeholder="" data-field="extra_notes" tabindex="5" style="resize: none; height: 7rem"></textarea>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="form-switch mb-4 ps-0">
<label for="subActive" class="form-check-label mb-2">Active</label>
<br>
<input type="checkbox" id="subActive" name="subActive" class="form-check-input ms-0 mt-0" data-field="active" tabindex="6">
<br>
<div class="form-text">Inactive subscriptions wont be processed.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer px-4">
<!-- form-create -->
<button type="button" id="devGenerateSub" class="btn btn-outline-info rounded-1 me-3 ms-0 d-none" tabindex="7">&#40;Dev&#41; Generate</button>
<button type="button" id="deleteEditSub" class="btn btn-danger rounded-1 me-3 ms-0 form-edit" tabindex="8">Delete</button>
<button type="button" class="btn btn-outline-primary rounded-1 me-auto ms-0" data-bs-toggle="modal" data-bs-target="#subAdvancedModal" tabindex="9">Advanced</button>
<button type="submit" class="btn btn-primary rounded-1 me-0" tabindex="9">
<span class="form-create">Create</span>
<span class="form-edit">Confirm Edit</span>
</button>
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3" data-bs-dismiss="modal" tabindex="10">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<div id="subAdvancedModal" class="modal modal-lg fade" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-1">
<form id="subAdvancedForm" class="mb-0" novalidate>
<div class="modal-header">
<h5 class="modal-title ms-2">
<span class="form-create">Add</span>
<span class="form-edit">Edit</span>
Subscription · Advanced
</h5>
</div>
<div class="modal-body p-4">
<div class="row">
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<label for="subTitleMutators" class="form-label">Title Mutators</label>
<select name="subTitleMutators" id="subTitleMutators" class="select-2 sub-mutators-field" multiple data-dropdownparent="#subAdvancedModal" tabindex="1"></select>
<div class="form-text">Apply mutators to article titles.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<label for="subDescMutators" class="form-label">Description Mutators</label>
<select name="subDescMutators" id="subDescMutators" class="select-2 sub-mutators-field" multiple data-dropdownparent="#subAdvancedModal" tabindex="2"></select>
<div class="form-text">Apply mutators to article descriptions.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4 d-none">
<div class="mb-4">
<label for="" class="form-label">Article Fetch Limit</label>
<input type="number" id="subFetchLimit" class="form-control rounded-1" max="10" min="1" tabindex="3">
<div class="form-text">Limit the number of articles fetched every cycle.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4 d-none">
<div class="form-switch ps-0 mb-4">
<label for="subResetFetchLimit" class="form-check-label mb-2">Max Fetch Limit after the First Cycle</label>
<br>
<input type="checkbox" id="subResetFetchLimit" name="subResetFetchLimit" class="form-check-input ms-0 mt-0" tabindex="4">
<br>
<div class="form-text">Sets the Fetch Limit to 10 after the first cycle. Helps with initial spam.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<div class="colour-input"
data-id="subEmbedColour"
data-label="Embed Colour"
data-helptext="Colour of each embed in Discord."
data-tabindex="5">
</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<label for="subPubThreshold" class="form-label">Publish Datetime Threshold</label>
<input type="datetime-local" name="subPubThreshold" id="subPubThreshold" class="form-control" data-field="published_threshold" tabindex="9">
<div class="form-text">RSS content older than this datetime will be skipped.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="form-switch ps-0">
<label for="subArticleFetchImage" class="form-check-label mb-2">Show Images on Embed?</label>
<br>
<input type="checkbox" id="subArticleFetchImage" name="subArticleFetchImage" class="form-check-input ms-0 mt-0" data-field="article_fetch_image" tabindex="10">
<br>
<div class="form-text">Show images on the discord embed?</div>
</div>
</div>
</div>
</div>
<div class="modal-footer px-4">
<button type="button" class="btn btn-primary rounded-1 me-0 ms-3" data-bs-toggle="modal" data-bs-target="#subFormModal" tabindex="11">Back</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -1,129 +0,0 @@
{% extends 'layouts/base.html' %}
{% load static %}
{% block title %}{% endblock title %}
{% block stylesheets %}
<link type="text/css" rel="stylesheet" href="{% static '/css/home/index.css' %}">
<link type="text/css" rel="stylesheet" href="{% static '/css/select2.css' %}">
{% endblock stylesheets %}
{% block content %}
<div class="container-lg px-0 h-100">
<div class="d-flex flex-nowrap h-100 border-start border-end">
<div class="d-flex flex-column bg-body-tertiary py-3 border-end" style="width: 4.5rem">
<ul id="serverList" class="nav nav-pills nav-flush flex-column mb-auto text-center">
<li class="nav-item">
<button type="button" id="newServerBtn" class="btn btn-outline-primary rounded-1 mt-1" style="width: 46px; height: 46px;">
<i class="bi bi-plus-lg fs-5"></i>
</button>
</li>
</ul>
</div>
<div class="flex-grow-1 container-fluid bg-body overflow-y-auto" style="min-width: 0;">
<div id="noSelectedServer" class="h-100">
<div class="d-flex justify-content-center align-items-center flex-column h-100">
<img src="{% static '/images/pyrss_logo.webp' %}" alt="PYRSS Logo">
<h1 class="fw-bold mb-4 font-atkinson-hyperlegible">PYRSS</h1>
<div class="d-flex align-items-center flex-nowrap flex-column">
<p class="col-lg-8 text-center">Select or <a onclick="newServerModal();" class="text-link text-decoration-none" role="button">add a server</a> from the left hand menu to get started. For more help check the <a href="https://gitea.corbz.dev/corbz/PYRSS-Website/src/branch/master/README.md" class="text-decoration-none" target="_blank">README</a>.</p>
<div class="col-lg-8 text-center">
<h5>Resources</h5>
<div class="hstack gap-3 justify-content-center">
<a href="https://gitea.corbz.dev/corbz/PYRSS-Website" class="text-body text-decoration-none" target="_blank"><i class="bi bi-git fs-3"></i></a>
<a href="https://en.wikipedia.org/wiki/RSS" class="text-body text-decoration-none" target="_blank"><i class="bi bi-rss-fill fs-3"></i></a>
<a href="https://discord.com/developers/docs/intro" class="text-body text-decoration-none" target="_blank"><i class="bi bi-discord fs-3"></i></a>
<a href="https://gitea.corbz.dev/corbz/PYRSS-Website/src/branch/master/README.md" class="text-body text-decoration-none" target="_blank"><i class="bi bi-question-circle-fill fs-3"></i></a>
</div>
</div>
</div>
</div>
</div>
<div id="selectedServerContainer" class="row" style="display: none;">
<div class="col-12 bg-body-tertiary border-bottom">
<div class="px-3 py-4 d-flex justify-content-start align-items-center">
<img alt="Selected Server Icon" class="rounded-3 selected-server-icon">
<div class="ms-3" style="min-width: 0">
<h3 class="mb-0 resolve-to-server-name text-truncate"></h3>
<h5 class="mb-0 resolve-to-server-id text-truncate text-body-secondary"></h5>
</div>
<div class="ms-auto">
<button type="button" id="serverSettingsBtn" class="btn btn-outline-secondary rounded-1 ms-3" data-bs-toggle="tooltip" data-bs-title="Server settings">
<i class="bi bi-gear"></i>
</button>
<button type="button" id="deleteSelectedServerBtn" class="btn btn-outline-danger rounded-1 ms-3" data-bs-toggle="tooltip" data-bs-title="Close server">
<i class="bi bi-x-lg"></i>
</button>
<button type="button" id="backToSelectServer" class="btn btn-outline-secondary rounded-1 ms-3" data-bs-toggle="tooltip" data-bs-title="Go back">
<i class="bi bi-box-arrow-right"></i>
</button>
</div>
</div>
</div>
<div id="serverJoinAlert" class="col-12 m-0">
<div class="px-3 mt-4 mx-2 alert alert-warning fade show rounded-1 d-flex align-items-center">
<div class="me-4">
<strong>Warning:</strong>
The Bot isn't a member of
<span class="resolve-to-server-name"></span>,
features here will not function properly, please add the bot before proceeding.
</div>
<a class="ms-auto btn btn-warning rounded-1 text-nowrap resolve-to-invite-link" target="_blank">Add PYRSS</a>
</div>
</div>
<div class="col-12">
<ul id="serverTabs" class="nav py-3" role="tablist">
<li class="nav-item" role="presentation">
<button id="subscriptionsTab" class="nav-link" data-bs-toggle="tab" data-bs-target="#subscriptionsTabPane" type="button" aria-controls="subscriptionsTabPane" aria-selected="false">Subscriptions</button>
</li>
<li class="nav-item" role="presentation">
<button id="filtersTab" class="nav-link" data-bs-toggle="tab" data-bs-target="#filtersTabPane" type="button" aria-controls="filtersTabPane" aria-selected="false">Content Filters</button>
</li>
<li class="nav-item" role="presentation">
<button id="contentTab" class="nav-link" data-bs-toggle="tab" data-bs-target="#contentTabPane" type="button" aria-controls="contentTabPane" aria-selected="false">Tracked Content</button>
</li>
<!-- <li class="nav-item ms-auto" role="presentation">
<button id="settingsTab" class="nav-link" data-bs-toggle="tab" data-bs-target="#settingsTabPane" type="button" aria-controls="settingsTabPane" aria-selected="false">Settings</button>
</li> -->
</ul>
</div>
<div class="col-12">
<div id="serverTabContent" class="tab-content">
<div id="subscriptionsTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="subscriptionsTab" tabindex="0"></div>
<div id="filtersTabPane" class="tab-pane fade includes-table includes-table-controls includes-table-search" role="tabpanel" aria-labelledby="filtersTab" tabindex="0"> </div>
<div id="contentTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="contentTab" tabindex="0"></div>
<!-- <div id="settingsTabPane" class="tab-pane fade" role="tabpanel" aria-labelledby="settingsTab" tabindex="0">{% include "home/includes/settingstab.html" %}</div> -->
</div>
</div>
</div>
</div>
</div>
</div>
{% include "home/includes/servermodal.html" %}
{% include "home/includes/submodal.html" %}
{% include "home/includes/filtermodal.html" %}
{% include "home/includes/deletemodal.html" %}
{% include "home/includes/settingsmodal.html" %}
{% endblock content %}
{% block javascript %}
<script id="serverItemTemplate" type="text/template">
<li class="nav-item server-item" data-id="" data-bs-toggle="tooltip" data-bs-placement="right">
<button type="button" class="btn border-0 server-item-selector mb-2">
<img src="" alt="Guild Icon" class="rounded-circle" width="46" height="46">
</button>
</li>
</script>
<script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/table.js' %}"></script>
<script src="{% static 'js/home/index.js' %}"></script>
<script src="{% static 'js/home/servers.js' %}"></script>
<script src="{% static 'js/home/subscriptions.js' %}"></script>
<script src="{% static 'js/home/filters.js' %}"></script>
<script src="{% static 'js/home/content.js' %}"></script>
<script src="{% static 'js/home/settings.js' %}"></script>
{% endblock javascript %}

View File

@ -1,19 +0,0 @@
<div class="container-lg px-0">
<footer class="border border-bottom-0 bg-body-secondary px-4 py-3 d-flex flex-wrap justify-content-between align-items-center text-body-secondary">
<div class="col-md-4 d-flex align-items-center">
<span>&copy; 2024 PYRSS</span>
</div>
<ul class="nav col-md-4 d-flex justify-content-end list-unstyled">
<li class="ms-3">
<a href="https://gitea.corbz.dev/corbz/PYRSS-Website" class="text-reset" target="_blank">
<i class="bi bi-git fs-5"></i>
</a>
</li>
<li class="ms-3">
<a href="https://gitea.corbz.dev/corbz/PYRSS-Website/wiki" class="text-reset" target="_blank">
<i class="bi bi-question-lg fs-5"></i>
</a>
</li>
</ul>
</footer>
</div>

View File

@ -1,6 +1,5 @@
# -*- encoding: utf-8 -*-
import os
import environ
import logging
from pathlib import Path
@ -14,6 +13,9 @@ VERSION = "0.3.4"
# BASE_DIR is the root of the project, all paths should be constructed from it using pathlib
BASE_DIR = Path(__file__).parent.parent
# region Env Vars
# Create an environment and read variables from .env file
env = environ.Env(DEBUG=(bool, True))
environ.Env.read_env(BASE_DIR / ".env")
@ -25,7 +27,10 @@ required_env_vars = (
for var in required_env_vars:
if not env(var, default=None):
log.warn("Required environment variable %s is not set, the application will fail!", var)
log.warning("Required environment variable %s is not set, the application will fail!", var)
# region Security
# SECURITY WARNING: This is sensitive data, keep secure!
SECRET_KEY = env('SECRET_KEY', default="unsecure-default-secret-key")
@ -37,7 +42,8 @@ DEBUG = env('DEBUG')
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "pyrss-website", env("HOST", default="127.0.0.1")]
CSRF_TRUSTED_ORIGINS = ["http://localhost", "http://127.0.0.1", "http://pyrss-website", "https://" + env("HOST", default="127.0.0.1")]
# Application definition
# region App definition
INSTALLED_APPS = [
'django.contrib.admin',
@ -49,6 +55,7 @@ INSTALLED_APPS = [
'rest_framework',
"rest_framework.authtoken",
"django_filters",
"compressor",
'apps.api',
'apps.home',
'apps.authentication',
@ -78,7 +85,7 @@ AUTH_USER_MODEL = "authentication.DiscordUser"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / "apps/templates"],
'DIRS': [BASE_DIR / "templates"],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -95,7 +102,7 @@ TEMPLATES = [
WSGI_APPLICATION = 'core.wsgi.application'
# Database
# region Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DB_ENGINE = env("DB_ENGINE", default=None)
@ -117,7 +124,7 @@ else:
DATABASES = { "default": db_data }
# Password validation
# region Passwd validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
@ -128,16 +135,15 @@ AUTH_PASSWORD_VALIDATORS = [
]
AUTHENTICATION_BACKENDS = [
# "django.contrib.auth.backends.ModelBackend",
"apps.authentication.backends.DiscordAuthenticationBackend"
]
# Discord Related Settings
# region Discord Settings
BOT_TOKEN = env("BOT_TOKEN", default=None)
DISCORD_KEY = env("DISCORD_KEY", default=None)
DISCORD_SECRET = env("DISCORD_SECRET", default=None)
DISCORD_SCOPES = env("DISCORD_SCOPES", default="identity,guilds").split(",") # ["identify", "guilds"]
DISCORD_SCOPES = env("DISCORD_SCOPES", default="identity,guilds").split(",") # ["identify", "guilds"]
DISCORD_CODE_EXCHANGE_REQUEST = {
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
"data": {
@ -160,12 +166,11 @@ DISCORD_API_URL = env("DISCORD_API_URL", default="https://discord.com/api/v10")
DISCORD_OAUTH2_URL = env("DISCORD_OAUTH2_URL", default=None)
SUPERUSER_IDS = env("SUPERUSER_IDS", default="").split(",")
# Logging
# region Logging
# https://docs.djangoproject.com/en/5.0/topics/logging/
LOGGING_DIR = BASE_DIR / "logs"
LOGGING_DIR.mkdir(exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
@ -218,34 +223,43 @@ LOGGING = {
}
# Internationalization
# region Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-gb'
TIME_ZONE = 'Europe/London'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# region Static files
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATIC_URL = '/static/'
# Extra places for collectstatic to find static files.
STATICFILES_DIRS = (
BASE_DIR / 'apps/static',
STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = "/static/"
STATICFILES_DIRS = (BASE_DIR / "static",) # Extra places for collectstatic to find static files.
STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
"compressor.finders.CompressorFinder",
)
# Media Files
# region SASS Compression
COMPRESS_ENABLED = True
COMPRESS_OFFLINE = not env("DEBUG")
COMPRESS_PRECOMPILERS = [("text/x-scss", "django_libsass.SassCompiler")]
COMPRESS_CSS_FILTERS = ["compressor.filters.css_default.CssAbsoluteIdentifier"]
LIBSASS_ADDITIONAL_INCLUDE_PATHS = [str(BASE_DIR / "static/bootstrap-5.3.3/scss")]
# region Media Files
MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = '/media/'
# Django Rest Framework
# region Rest Framework
# https://www.django-rest-framework.org/
REST_FRAMEWORK = {
@ -257,5 +271,17 @@ REST_FRAMEWORK = {
'anon': '100/day',
'user': '10000/hour'
},
'DEFAULT_RENDERER_CLASSES': [
'apps.api.renderers.FixedJSONRenderer',
# 'rest_framework.renderers.AdminRenderer',
'rest_framework.renderers.BrowsableAPIRenderer'
],
"EXCEPTION_HANDLER": "apps.api.exceptions.conflict_exception_handler"
}
# region Data logic
MAX_SUBSCRIPTIONS_PER_SERVER = env("MAX_SUBSCRIPTIONS_PER_SERVER", default=15)
MAX_FILTERS_PER_SERVER = env("MAX_FILTERS_PER_SERVER", default=15)
MAX_MESSAGE_STYLES_PER_SERVER = env("MAX_MESSAGE_STYLES_PER_SERVER", default=15)

View File

@ -1,16 +1,26 @@
anyio==4.6.0
asgiref==3.8.1
bump2version==1.0.1
certifi==2024.2.2
charset-normalizer==3.3.2
Django==5.0.4
django-appconf==1.0.6
django-compressor==4.5.1
django-environ==0.11.2
django-filter==24.2
django-libsass==0.9
djangorestframework==3.15.1
gunicorn==23.0.0
h11==0.14.0
httpcore==1.0.5
httpx==0.27.2
idna==3.7
libsass==0.23.0
packaging==24.1
psycopg2==2.9.9
rcssmin==1.1.2
requests==2.31.0
rjsmin==1.2.2
setuptools==72.1.0
sniffio==1.3.1
sqlparse==0.5.0

View File

@ -1,6 +1,7 @@
#!/bin/sh
python manage.py collectstatic
python manage.py compress
python manage.py migrate
exec "$@"

12
static/bootstrap-5.3.3/.babelrc.js vendored Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
loose: true,
bugfixes: true,
modules: false
}
]
]
};

View File

@ -0,0 +1,11 @@
# https://github.com/browserslist/browserslist#readme
>= 0.5%
last 2 major versions
not dead
Chrome >= 60
Firefox >= 60
Firefox ESR
iOS >= 12
Safari >= 12
not Explorer <= 11

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