Compare commits

...

142 Commits

Author SHA1 Message Date
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
161 changed files with 4273 additions and 4187 deletions

View File

@ -1,73 +1,191 @@
# 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]
- 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**
- `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
- 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**
- Footer links pointing to older domain
- Tracked content ListView API incorrectly ordering by oldest first, instead of newest first
- Fix: issues brought from previous version
### Changed
**breaking changes v.0.3.0**
- 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
- 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`
### Removed
**v0.2.2**
- Unused code for the legacy 'server settings tab' from before it became a modal
- 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>`.
## [0.3.4] - 2024-09-12
**v0.2.1**
### Added
- Enhancement: Added confirmation modal for closing a server
- Add wiki link button to footer
**v0.2.0**
### Fixed
- 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
- 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
**v0.1.14**
### Changed
- Fix: layout issue for the 'select a server' page was off-centre
- `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
**v0.1.13**
## [0.3.3] - 2024-08-24
- Docs: Start of changelog
- Fix: remove db flush from entrypoint file
### Added
**v0.1.0**
- Added font: sora
- Added font: Atkinson Hyperlegible
- added license file
- Initial Release
### 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,25 @@
# -*- 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,
DiscordChannel,
Subscription,
Content,
UniqueContentRule
)
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 +119,154 @@ class DynamicModelSerializer(serializers.ModelSerializer):
abstract = True
class SubChannelSerializer(DynamicModelSerializer):
"""
Serializer for SubChannel Model.
"""
class ServerSerializer(DynamicModelSerializer):
class Meta:
model = SubChannel
fields = ("id", "channel_id", "channel_name", "subscription")
model = Server
fields = ("id", "name", "icon_hash", "active")
class FilterSerializer(DynamicModelSerializer):
"""
Serializer for the Filter Model.
"""
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):
class MessageMutatorSerializer(DynamicModelSerializer):
class Meta:
model = ArticleMutator
model = MessageMutator
fields = ("id", "name", "value")
class SubscriptionSerializer_GET(DynamicModelSerializer):
"""
Serializer for the Subscription Model.
"""
class MessageStyleSerializer(DynamicModelSerializer):
class Meta:
model = MessageStyle
fields = (
"id",
"server",
"name",
"is_embed",
"is_hyperlinked",
"show_author",
"show_timestamp",
"show_images",
"fetch_images",
"title_mutator",
"description_mutator",
"auto_created"
)
read_only_fields = ("auto_created",)
article_title_mutators = ArticleMutatorSerializer(many=True)
article_desc_mutators = ArticleMutatorSerializer(many=True)
active = serializers.BooleanField(initial=True)
class UniqueContentRuleSerializer(DynamicModelSerializer):
class Meta:
model = UniqueContentRule
fields = ("id", "name", "value")
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
)
channels_detail = serializers.SerializerMethodField()
# unique_rules = UniqueContentRuleSerializ er(many=True) # TODO: solve? causes issues with submission.
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",
"message_style",
"unique_rules"
)
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 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
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,105 @@
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", "active"]
@admin.register(ContentFilter)
class ContentFilterAdmin(admin.ModelAdmin):
list_display = [
"id",
"server",
"name",
"match",
"matching_algorithm",
"is_insensitive",
"is_whitelist"
]
@admin.register(MessageMutator)
class MessageMutatorAdmin(admin.ModelAdmin):
list_display = [
"id",
"name",
"value"
]
@admin.register(MessageStyle)
class MessageStyleAdmin(admin.ModelAdmin):
list_display = [
"id",
"name",
"server",
"is_embed",
"is_hyperlinked",
"show_author",
"show_timestamp",
"show_images",
"fetch_images",
"title_mutator",
"description_mutator",
"auto_created"
]
@admin.register(DiscordChannel)
class DiscordChannelAdmin(admin.ModelAdmin):
list_display = [
"id",
"server",
"name",
"is_nsfw"
]
@admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin):
class Subscription(admin.ModelAdmin):
list_display = [
"id", "name", "url", "guild_id",
"creation_datetime", "active"
]
@admin.register(SubChannel)
class SubChannelAdmin(admin.ModelAdmin):
list_display = [
"id", "channel_id", "subscription"
"id",
"server",
"name",
"url",
"created_at",
"updated_at",
"extra_notes",
"active",
"message_style"
]
@admin.register(Filter)
class FilterAdmin(admin.ModelAdmin):
@admin.register(Content)
class ContentAdmin(admin.ModelAdmin):
list_display = [
"id", "name", "guild_id"
"id",
"subscription",
"item_id",
"item_guid",
"item_url",
"item_title",
"item_content_hash"
]
@admin.register(TrackedContent)
class TrackedContentAdmin(admin.ModelAdmin):
@admin.register(UniqueContentRule)
class UniqueContentRule(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"
]

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

@ -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

@ -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

@ -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

@ -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,54 @@
# -*- 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.PositiveIntegerField(primary_key=True)
name = models.CharField(max_length=128)
icon_hash = models.CharField(max_length=128, blank=True, 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 +64,234 @@ 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'")
super().__init__(self)
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.PositiveIntegerField(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=True)
filters = models.ManyToManyField(to=ContentFilter, blank=True)
message_style = models.ForeignKey(to=MessageStyle, on_delete=models.SET_NULL, null=True, blank=True)
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

@ -0,0 +1,126 @@
@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-sidebar {
width: 16rem;
}
@media (max-width: 992px) {
.server-sidebar {
width: 4.5rem;
}
.server-item > button {
padding: 0.25rem !important;
}
.server-item > button > div {
justify-content: center !important;
}
.server-item .server-item-labels {
display: none !important;
}
.server-sidebar > ul > li:has(> #newServerBtn) {
display: flex;
justify-content: center !important;
}
#newServerBtn {
width: 45px !important;
height: 45px !important;
padding: 0 !important;
}
}
.server-item {
max-width: calc(16rem - 1rem);
}
.server-item-selector {
display: flex;
justify-content: flex-start;
align-items: center;
flex-wrap: nowrap;
color: var(--bs-text-body);
background-color: var(--bg-tertiary-bg);
transition: 0.3s;
}
.server-item-selector .font-monospace {
color: var(--bs-secondary-color)
}
.server-item-selector:hover,
.server-item-selector:focus,
.server-item-selector:active,
.active > .server-item-selector {
background-color: var(--bs-tertiary-bg) !important;
}
/* 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:not(:last-child) {
margin-right: 1rem;
}
#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);
}
/* Yellow Dot Indicator for the server sidebar */
.dot-container {
width: .75rem;
height: .75rem;
display: flex;
position: absolute;
top: 0;
right: 0;
}
@media (max-width: 992px) {
.dot-container {
transform: translate(25%, -25%);
}
}
.dot-icon {
height: .75rem;
width: .75rem;
background-color: var(--bs-primary);
border-radius: 50%;
display: inline-block;
}

View File

@ -0,0 +1,118 @@
.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);
}

View File

@ -1,18 +1,24 @@
$(document).ready(async function() {
await initSubscriptionTable();
await initFiltersTable();
await initContentTable();
// 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 +27,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 +90,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());
});
@ -122,12 +137,12 @@ $(document).ready(function() {
$(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}">
<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,14 +170,43 @@ $(document).ready(function() {
});
});
async function confirmationModal(title, bodyText, style, acceptFunc, declineFunc) {
let $modal = $("#confirmationModal");
// Ensure valid style and apply it to the confirm button
// region OK modal
function validateBootstrapStyle(style) {
if (!["danger", "success", "warning", "info", "primary", "secondary"].includes(style)) {
throw new Error(`${style} is not a valid style`);
}
return true;
}
async function okModal(title, bodyText, style, iconClass, func) {
let $modal = $("#okModal");
validateBootstrapStyle(style);
$modal.find(".modal-dismiss-btn").addClass(`btn-${style}`);
$modal.find(".modal-dismiss-btn > i").addClass(iconClass);
$modal.find(".modal-title").text(title);
$modal.find(".modal-body > p").html(bodyText);
$modal.find(".modal-dismiss-btn").off("click").on("click", async function(e) {
if (func) await func();
$modal.modal("hide");
});
$modal.modal("show");
}
// region Confirm Modal
async function confirmationModal(title, bodyText, style, iconClass, acceptFunc, declineFunc) {
let $modal = $("#confirmModal");
validateBootstrapStyle(style);
$modal.find(".modal-confirm-btn").addClass(`btn-${style}`);
$modal.find(".modal-confirm-btn > i").addClass(iconClass);
$modal.find(".modal-title").text(title);
$modal.find(".modal-body > p").html(bodyText);
@ -189,4 +233,28 @@ function arrayToHtmlList(array, bold=false) {
});
return $ul;
}
// 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);
}
}

View File

@ -0,0 +1,200 @@
// 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 = {};
function loadedChannels(serverId) {
if (!(serverId in _loadedChannels)) {
throw new Error(`channels not loaded for server: ${serverId}`);
}
return _loadedChannels[serverId]
}
$(document).on("selectedServerChange", async function() {
serverId = selectedServer.id; // take note incase 'selectedServer' changes
ajaxRequest(`/generate-channels?guild=${serverId}`, "GET")
.then(channels => {
_loadedChannels[serverId] = channels;
})
.catch(error => {
logError(error);
});
});
// region UI Buttons
function createSelectButton(serverData) {
// server details
let id = serverData["id"];
let name = serverData["name"];
let iconHash = serverData["icon"];
let template = $($("#serverItemTemplate").html());
let imageUrl = `https://cdn.discordapp.com/icons/${id}/${iconHash}.webp?size=80`;
let altText = name.split(' ').map(word => word.charAt(0)).join(''); // initials of server name, used if iconUrl is 404
template.find("img").attr("src", imageUrl).attr("alt", altText);
template.find(".js-guildName").text(name);
template.find(".js-guildId").text(id);
template.attr("data-id", id);
// Bind the button for selecting this server
template.find(".server-item-selector").off("click").on("click", function() {
selectServer(id);
});
$("#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;
});
// #endregion
// #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 .server-item").removeClass("active");
$(`#serverList .server-item[data-id=${id}]`).addClass("active");
// Global variable
selectedServer = server;
// Update UI
$("#noSelectedServer").hide();
$("#selectedServerContainer").show().css("display", "flex");
// Announce change to any listeners
$(document).trigger("selectedServerChange");
}
// #endregion
// #region Resolve Strings
function resolveServerStrings() {
// Server icon
$(".resolve-to-server-icon").attr(
"src",
`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.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() {
$(".server-loading-item").show();
generateServers()
.then(servers => {
servers.forEach(server => addToLoadedServers(server, false));
})
.catch(error => {
switch (error.status) {
case 401:
window.location.href = "/login"; // discord token has expired
break;
case 429:
$(".server-rate-limit").show();
break;
default:
logError(error);
break;
}
})
.finally(() => {
$(".server-loading-item").hide();
});
}
// #endregion

View File

@ -0,0 +1,478 @@
// 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: "text-center col-switch-width",
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;
}
$(document).on("selectedServerChange", function() {
_tableFilters = {};
_tableOrdering = {};
});
// 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);
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 {
$(this).val(defaultVal).change();
}
});
}
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 {
$(this).val(value).change();
}
});
}
async function onModalSubmit($modal, $table, url) {
if (!selectedServer) {
return;
}
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;
if (type === "checkbox") {
value = $(this).prop("checked");
}
else {
value = $(this).val();
}
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 => {
$modal.modal("hide");
$table.trigger("doDataLoad");
})
.catch(error => logError(error));
}
// region Table Column Types
function renderEditColumn(data) {
const name = sanitise(data);
return `<button type="button" class="btn btn-link text-start text-decoration-none edit-modal">${name}</button>`;
}
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 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();
}
// 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,111 @@
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");
}
// 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,235 @@
const styleTableId = "#styleTable";
const styleModalId = "#styleFormModal";
// region Init Module
function initMessageStylesModule() {
initializeDataTable(
styleTableId,
[
{
title: "Name",
data: "name",
render: function(data, type, row) {
const btn = renderEditColumn(data);
return row.auto_created ?
$(btn).removeClass("edit-modal").addClass("disabled")[0]
: btn;
}
},
{
title: "Is Embed",
data: "is_embed",
className: "text-center",
render: renderBooleanColumn
},
{
title: "Is Hyperlinked",
data: "is_hyperlinked",
className: "text-center",
render: renderBooleanColumn
},
{
title: "Show Author",
data: "show_author",
className: "text-center",
render: renderBooleanColumn
},
{
title: "Show Timestamp",
data: "show_timestamp",
className: "text-center",
render: renderBooleanColumn
},
{
title: "Show Images",
data: "show_images",
className: "text-center",
render: renderBooleanColumn
},
{
title: "Fetch Images",
data: "fetch_images",
className: "text-center",
render: renderBooleanColumn
},
{
title: "Title Mutator",
data: "title_mutator"
},
{
title: "Description Mutator",
data: "description_mutator"
},
{
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);
await confirmationModal(
`Delete a Message Style`,
`Do you wish to permanently delete <b>${name}</b>?`,
"danger",
"bi-trash3",
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);
},
() => { $(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;
}
await okModal(
"Cannot Delete Style",
`<b>${row.name}</b> can't be deleted, as it was created by the system, and thus is immutable.`,
"warning",
"bi-arrow-return-right",
null,
);
return
}
const names = rows.map(row => row.name);
const namesString = arrayToHtmlList(names, true).prop("outerHTML");
await confirmationModal(
`Delete ${isMany ? "Many Message Styles" : "a Message Style"}`,
`Do you wish to permanently delete ${isMany ? "these" : "this"} <b>${names.length}</b> message style${isMany ? "s" : ""}?<br><br>${namesString}`,
"danger",
"bi-trash3",
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);
},
null
)
});
// 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 () => {
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,254 @@
const subTableId = "#subTable";
const subModalId = "#subFormModal";
// region Init Module
function initSubscriptionsModule() {
initializeDataTable(
subTableId,
[
{
title: "Name",
data: "name",
className: "text-truncate col-3",
render: renderEditColumn
},
{
title: "URL",
data: "url",
className: "text-truncate col-4",
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_detail",
className: "text-center",
render: data => renderArrayBadgesColumn(data.map(item => "#" + item.name))
},
{
title: "Rules",
data: "unique_rules",
className: "text-center text-nowrap col-1",
render: function(data, type) {
let badges = $("<div>");
data.forEach(function(rule, idx) {
let badge = $(`<span class="badge text-bg-secondary">${rule}</span>`) // TODO: translate to rule.name
if (idx > 0) { badge.addClass("ms-2") }
badges.append(badge);
});
return badges.html();
}
},
{
title: "Created At",
data: "created_at",
className: "col-3",
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",
orderable: false,
className: "text-center form-switch col-1",
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);
await confirmationModal(
`Delete a Subscription`,
`Do you wish to permanently delete <b>${name}</b>?`,
"danger",
async () => {
await ajaxRequest(`/api/subscriptions/${subscription.id}/`, "DELETE");
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
},
() => { $(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 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 ajaxRequest(`/api/subscriptions/${row.id}/`, "DELETE");
});
setTimeout(() => { $(subTableId).trigger("doDataLoad") }, 600);
},
null
)
});
// 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.
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 = loadedChannels(selectedServer.id);
data.forEach(item => {
$input.append($(
"<option>",
{text: `#${item.name}`, value: item.id}
));
})
$input.prop("disabled", false);
}

View File

@ -0,0 +1,209 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{% endblock title %}
{% block stylesheets %}
<link type="text/css" rel="stylesheet" href="{% static '/home/css/index.css' %}">
<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 bg-body-secondary">
<div class="d-flex flex-nowrap h-100">
<div class="server-sidebar d-flex flex-column bg-body-secondary py-3">
<h6 class="d-none d-lg-block ms-3">Discord Servers</h6>
<ul id="serverList" class="nav nav-pills nav-flush flex-column mb-auto text-center px-lg-2 px-1 flex-nowrap overflow-y-auto">
{% for i in "0123456789"|make_list %}
<li class="nav-item server-loading-item">
<div class="btn border-0 w-100 position-relative">
<div class="d-flex justify-content-start align-items-center w-100 placeholder-wave">
<div class="rounded-1 placeholder" style="min-width: 45px !important; min-height: 45px !important;"></div>
<div class="text-start ps-2 w-100 flex-shrink-1 placeholder-wave">
{% if forloop.counter0|divisibleby:2 %}
<div class="placeholder rounded-1 w-75"></div>
<div class="placeholder rounded-1 w-100"></div>
{% else %}
<div class="placeholder rounded-1 w-75"></div>
<div class="placeholder rounded-1 w-50"></div>
{% endif %}
</div>
</div>
</div>
</li>
{% endfor %}
<li class="nav-item server-rate-limit" style="display: none;">
<div class="alert alert-warning text-start" role="alert">
<p class="small">You are being rate limited.</p>
<p class="small mb-0">Refresh the page to try again.</p>
</div>
</li>
</ul>
<ul class="d-flex flex-lg-row flex-column list-unstyled text-center flex-nowrap mb-0 px-lg-4 px-2 align-items-center text-body-secondary">
<li>
<a href="https://gitea.cor.bz/corbz/PYRSS-Website" class="text-reset" target="_blank">
<i class="bi bi-git fs-6"></i>
</a>
</li>
<li class="ms-lg-4 ms-0">
<a href="https://gitea.cor.bz/corbz/PYRSS-Website/wiki" class="text-reset" target="_blank">
<i class="bi bi-question-lg fs-6"></i>
</a>
</li>
<li class="mx-auto d-none d-lg-inline">
<div class="vr"></div>
</li>
<li class="d-none d-lg-inline text-nowrap text-reset small">
&copy; 2024 PYRSS
</li>
</ul>
</div>
<div class="flex-grow-1 container-fluid bg-body rounded-top-2 rounded-end-0 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">
<ul id="serverTabs" class="nav nav-pills py-3 text-start px-sm-3" role="tablist">
<li class="nav-item me-auto">
<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 text-body-secondary"></h6>
</div>
</div>
</li>
<li class="nav-item" 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-md-inline">Subscriptions</span>
</button>
</li>
<li class="nav-item" 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-md-inline">Content Filters</span>
</button>
</li>
<li class="nav-item" 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-md-inline">Message Styles</span>
</button>
</li>
<li class="nav-item me-0" 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-md-inline">Tracked Content</span>
</button>
</li>
<li class="nav-item d-none">
<div class="dropdown">
<button class="nav-link rounded-1 dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-gear"></i>
</button>
<ul class="dropdown-menu rounded-1">
<li>
<button type="button" class="dropdown-item py-2">
<i class="bi bi-trash3"></i>
<span class="ms-2">Delete Server Data</span>
</button>
</li>
</ul>
</div>
</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">
{% 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/confirm.html" %}
{% endblock content %}
{% block javascript %}
<script id="serverItemTemplate" type="text/template">
<li class="nav-item server-item" data-id="" data-guild-id="">
<button type="button" class="btn border-0 server-item-selector w-100 rounded-1 position-relative">
<div class="d-flex justify-content-start align-items-center w-100">
<img src="" alt="Guild Icon" class="rounded-1 small d-flex justify-content-center align-items-center" width="45" height="45">
<div class="server-item-labels small text-start ps-2 w-100 flex-shrink-1 overflow-hidden">
<div class="js-guildName fw-bold text-truncate"></div>
<div class="js-guildId text-truncate font-monospace"></div>
</div>
</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/tables.js' %}"></script>
<script src="{% static 'home/js/servers.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,38 @@
<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="okModal" 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 ms-3 ms-0 px-4 modal-dismiss-btn" tabindex="2">
<i class="bi"></i>
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,127 @@
<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-dropdownparent="#filterFormModal" 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>
<!-- <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-12">
<div class="form-check form-switch mb-4">
<label for="filterWhitelist" class="form-check-label">Whitelist</label>
<input type="checkbox" id="filterWhitelist" name="filterWhitelist" class="form-check-input" tabindex="4">
<div class="form-text">Allow only matches, instead of blocking them.</div>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<label for="filterInsensitive" class="form-check-label">Case Insensitive</label>
<input type="checkbox" id="filterInsensitive" name="filterInsensitive" class="form-check-input" tabindex="5">
<div class="form-text">Ignore case sensitivity when matching.</div>
</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

@ -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,98 @@
<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>
<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-dropdownparent="#styleFormModal" 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-dropdownparent="#styleFormModal" 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" 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-dropdownparent="#subFormModal" data-field="message_style" 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-dropdownparent="#subFormModal" 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-dropdownparent="#subFormModal" 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>
<label for="subUniqueRules" class="form-label">Uniqueness Rules</label>
<select name="subUniqueRules" id="subUniqueRules" class="select-2" multiple data-dropdownparent="#subFormModal" 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 d-none">Disabled Subscriptions are ignored.</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,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-search-buttons">
<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 align-middle"></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 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-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 align-middle"></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-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">
<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 align-middle"></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 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-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="subTable" class="table table-hover align-middle"></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,21 @@
# -*- encoding: utf-8 -*-
from django.views.generic import TemplateView
import json
import logging
import httpx
from django.conf import settings
from django.utils import timezone
from asgiref.sync import sync_to_async
from django.shortcuts import redirect
from django.http import JsonResponse, HttpResponse
from django.views.generic import TemplateView, View
from django.core.exceptions import PermissionDenied
from apps.home.models import Server, DiscordChannel
from apps.authentication.models import DiscordUser, ServerMember
log = logging.getLogger(__name__)
class IndexView(TemplateView):
@ -9,3 +24,158 @@ 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):
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)
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}"}
)
status = response.status_code
guild_data = response.json()
if status != 200:
return JsonResponse(guild_data, status=status, safe=False)
# guilds where the user either administrates or owns
cleaned_guild_data = []
for item in guild_data:
cleaned_data = await self.setup_server(request.user, item)
if cleaned_data:
cleaned_guild_data.append(cleaned_data)
return JsonResponse(cleaned_guild_data, safe=False)
async def setup_server(self, user: DiscordUser, data: dict):
is_owner = data["owner"]
permissions = data["permissions"]
admin_perm = 1 << 3
# Ignore servers where the user isn't an administrator or owner
if not ((int(permissions) & admin_perm) == admin_perm or is_owner):
await self.delete_member(user, data["id"])
return
server = await Server.objects.aupdate_or_create(
id=data["id"],
defaults={
"name": data["name"],
"icon_hash": data["icon"]
}
)
await ServerMember.objects.aupdate_or_create(
user=user,
server=server[0],
defaults={
"permissions": permissions,
"is_owner": is_owner
}
)
return data
@staticmethod
async def delete_member(user: DiscordUser, server_id: int):
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")
server = await Server.objects.aget(pk=guild_id)
if not ServerMember.objects.filter(server=server, user=request.user).aexists():
raise PermissionDenied("You aren't a member of this server.")
channels_data, status = await self._get_channel_data(guild_id)
if status != 200:
return JsonResponse(channels_data, status=status, safe=False)
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`.
"""
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,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

@ -25,7 +25,7 @@ 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)
# SECURITY WARNING: This is sensitive data, keep secure!
SECRET_KEY = env('SECRET_KEY', default="unsecure-default-secret-key")
@ -78,7 +78,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': [
@ -237,7 +237,7 @@ STATIC_URL = '/static/'
# Extra places for collectstatic to find static files.
STATICFILES_DIRS = (
BASE_DIR / 'apps/static',
BASE_DIR / 'static',
)
# Media Files
@ -257,5 +257,15 @@ 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"
}
# 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,3 +1,4 @@
anyio==4.6.0
asgiref==3.8.1
bump2version==1.0.1
certifi==2024.2.2
@ -7,6 +8,9 @@ django-environ==0.11.2
django-filter==24.2
djangorestframework==3.15.1
gunicorn==23.0.0
h11==0.14.0
httpcore==1.0.5
httpx==0.27.2
idna==3.7
packaging==24.1
psycopg2==2.9.9

View File

@ -74,4 +74,35 @@
.w-xxl-75 { width: 75%; }
.w-xxl-100 { width: 100%; }
.w-xxl-auto { width: auto; }
}
}
/* Navbar */
#navCollapse .navbar-nav {
height: auto;
padding-right: none;
}
@media (min-width: 992px) {
#navCollapse .navbar-nav {
height: 100%;
}
}
.navbar-btn {
text-align: start;
border: none;
width: 100%;
height: 100%;
padding: 0.5rem 1rem;
text-decoration: none;
border-radius: var(--bs-border-radius-sm);
color: var(--bs-body-color);
background-color: var(--bs-secondary-bg);
}
.navbar-btn:hover {
background-color: var(--bs-tertiary-bg);
}

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