Compare commits

..

3 Commits
dev ... master

Author SHA1 Message Date
ad982267a1 Merge pull request 'staging into master' (#69) from staging into master
All checks were successful
Build and Push Docker Image / build (push) Successful in 10s
Reviewed-on: #69
2024-10-14 22:34:03 +00:00
5237f6fbf9 Merge pull request 'v0.3.0' (#44) from staging into master
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
Reviewed-on: https://gitea.corbz.dev/corbz/PYRSS-Website/pulls/44
2024-08-16 18:51:49 +00:00
4d9877963e Merge pull request 'move changes into master' (#41) from staging into master
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
Reviewed-on: https://gitea.corbz.dev/corbz/PYRSS-Website/pulls/41
2024-08-14 19:32:18 +00:00
391 changed files with 4429 additions and 43239 deletions

View File

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

View File

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

View File

@ -2,19 +2,22 @@
from rest_framework.permissions import BasePermission
from apps.home.models import Server
from apps.authentication.models import ServerMember
class HasServerAccess(BasePermission):
class UserHasDiscordPermissions(BasePermission):
"""
An object permission class, the object must have a 'server' attribute.
Permission to ensure that the user is permitted to make
changes on behalf of the server they are representing.
"""
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'")
return ServerMember.objects.filter(user=request.user, server=obj.server).exists()
# 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

View File

@ -1,27 +0,0 @@
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,26 +1,15 @@
# -*- 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 (
Server,
ContentFilter,
MessageMutator,
MessageStyle,
DiscordChannel,
Subscription,
Content,
SubscriptionRecommendation
)
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
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.
@ -120,233 +109,108 @@ class DynamicModelSerializer(serializers.ModelSerializer):
abstract = True
# region Servers
class ServerSerializer(DynamicModelSerializer):
id = serializers.CharField()
class SubChannelSerializer(DynamicModelSerializer):
"""
Serializer for SubChannel Model.
"""
class Meta:
model = Server
fields = (
"id",
"name",
"icon_hash",
"is_bot_operational",
"active"
)
model = SubChannel
fields = ("id", "channel_id", "channel_name", "subscription")
# region Filters
class FilterSerializer(DynamicModelSerializer):
"""
Serializer for the Filter Model.
"""
class ContentFilterSerializer(DynamicModelSerializer):
class Meta:
model = ContentFilter
fields = (
"id",
"server",
"name",
"match",
"matching_algorithm",
"is_insensitive",
"is_whitelist"
)
model = Filter
fields = ("id", "name", "matching_algorithm", "match", "is_insensitive", "is_whitelist", "guild_id")
# region Msg Mutators
class ArticleMutatorSerializer(DynamicModelSerializer):
class MessageMutatorSerializer(DynamicModelSerializer):
class Meta:
model = MessageMutator
model = ArticleMutator
fields = ("id", "name", "value")
# region Msg Styles
class SubscriptionSerializer_GET(DynamicModelSerializer):
"""
Serializer for the Subscription Model.
"""
class MessageStyleSerializer(DynamicModelSerializer):
title_mutator_detail = serializers.SerializerMethodField()
description_mutator_detail = serializers.SerializerMethodField()
class Meta:
model = MessageStyle
fields = (
"id",
"server",
"name",
"is_embed",
"colour",
"is_hyperlinked",
"show_author",
"show_timestamp",
"show_images",
"fetch_images",
"title_mutator",
"title_mutator_detail",
"description_mutator",
"description_mutator_detail",
"auto_created"
)
read_only_fields = ("auto_created",)
def get_title_mutator_detail(self, obj: MessageStyle):
request = self.context.get("request")
if request and request.method == "GET":
return MessageMutatorSerializer(obj.title_mutator).data
return {}
def get_description_mutator_detail(self, obj: MessageStyle):
request = self.context.get("request")
if request and request.method == "GET":
return MessageMutatorSerializer(obj.description_mutator).data
return {}
# region Subscriptions
class DiscordChannelField(serializers.PrimaryKeyRelatedField):
def to_internal_value(self, data):
try:
data = int(data)
except (TypeError, ValueError):
self.fail("invalid", pk_value=data)
return super().to_internal_value(data)
def to_representation(self, value):
return str(value.pk)
class NestedDiscordChannelSerializer(DynamicModelSerializer):
class Meta:
model = DiscordChannel
fields = ("id", "name", "is_nsfw")
class SubscriptionSerializer(DynamicModelSerializer):
filters = serializers.PrimaryKeyRelatedField(
queryset=ContentFilter.objects.all(),
many=True
)
channels = DiscordChannelField(
queryset=DiscordChannel.objects.all(),
many=True,
required=True,
allow_empty=False
)
channels_detail = serializers.SerializerMethodField()
filters_detail = serializers.SerializerMethodField()
message_style = serializers.PrimaryKeyRelatedField(
queryset=MessageStyle.objects.all(),
required=True,
allow_null=False
)
message_style_detail = serializers.SerializerMethodField()
article_title_mutators = ArticleMutatorSerializer(many=True)
article_desc_mutators = ArticleMutatorSerializer(many=True)
active = serializers.BooleanField(initial=True)
class Meta:
model = Subscription
fields = (
"id",
"server",
"name",
"url",
"created_at",
"updated_at",
"extra_notes",
"active",
"publish_threshold",
"channels",
"channels_detail",
"filters",
"filters_detail",
"message_style",
"message_style_detail"
"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"
)
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 []
def get_filters_detail(self, obj: Subscription):
request = self.context.get("request")
if request.method == "GET":
return ContentFilterSerializer(obj.filters.all(), many=True).data
return []
class SubscriptionSerializer_POST(DynamicModelSerializer):
"""
Serializer for the Subscription Model.
"""
def get_message_style_detail(self, obj: Subscription):
request = self.context.get("request")
if request.method == "GET":
return MessageStyleSerializer(obj.message_style).data
return {}
def 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."}
)
# Prevent assigning more channels than permitted
channels = data.get("channels")
if len(channels) > settings.MAX_CHANNELS_PER_SUBSCRIPTION:
raise serializers.ValidationError(
{"channels": "Please select 5 channels or fewer."}
)
return data
# region Content
class ContentSerializer(DynamicModelSerializer):
class Meta:
model = Content
model = Subscription
fields = (
"id",
"subscription",
"item_id",
"item_guid",
"item_url",
"item_title",
"item_description",
"item_content_hash",
"item_image_url",
"item_thumbnail_url",
"item_published",
"item_author",
"item_author_url",
"item_feed_title",
"item_feed_url",
"blocked"
"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"
)
class SubscriptionRecommendationSerializer(DynamicModelSerializer):
class SavedGuildSerializer(DynamicModelSerializer):
"""
Serializer for the SavedGuild model.
"""
class Meta:
model = SubscriptionRecommendation
fields = (
"id",
"name",
"description",
"url"
)
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,63 +4,62 @@ from django.urls import path, include
from rest_framework.authtoken.views import obtain_auth_token
from .views import (
Server_ListView,
Server_DetailView,
ContentFilter_ListView,
ContentFilter_DetailView,
MessageMutator_ListView,
MessageMutator_DetailView,
MessageStyle_ListView,
MessageStyle_DetailView,
SubChannel_ListView,
SubChannel_DetailView,
Filter_ListView,
Filter_DetailView,
Subscription_ListView,
Subscription_DetailView,
Content_ListView,
Content_DetailView,
SubscriptionRecommendations_ListView
Subscription_SubChannelView,
SavedGuild_ListView,
SavedGuild_DetailView,
TrackedContent_ListView,
TrackedContent_DetailView,
ArticleMutator_ListView,
ArticleMutator_DetailView,
GuildSettings_ListView,
GuildSettings_DetailView
)
urlpatterns = [
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
path("api-token-auth/", obtain_auth_token),
# region Servers
path("servers/", include([
path("", Server_ListView.as_view()),
path("<int:pk>/", Server_DetailView.as_view())
path("subchannel/", include([
path("", SubChannel_ListView.as_view(), name="subchannel"),
path("<str:pk>/", SubChannel_DetailView.as_view(), name="subchannel-detail")
])),
# region Filters
path("filters/", include([
path("", ContentFilter_ListView.as_view()),
path("<int:pk>/", ContentFilter_DetailView.as_view())
path("filter/", include([
path("", Filter_ListView.as_view(), name="filter"),
path("<str:pk>/", Filter_DetailView.as_view(), name="filter-detail")
])),
# region Message Mutators
path("message-mutators/", include([
path("", MessageMutator_ListView.as_view()),
path("<int:pk>/", MessageMutator_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 Styles
path("message-styles/", include([
path("", MessageStyle_ListView.as_view()),
path("<int:pk>/", MessageStyle_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 Subscriptions
path("subscriptions/", include([
path("", Subscription_ListView.as_view()),
path("<int:pk>/", Subscription_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 Content
path("content/", include([
path("", Content_ListView.as_view()),
path("<int:pk>/", Content_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 Recommendations
path("subscription-recommendations/", include([
path("", SubscriptionRecommendations_ListView.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")
])),
]

View File

@ -2,37 +2,29 @@
import logging
from django.db.models import Q
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 permissions, filters, generics, status
from rest_framework import status, permissions, filters, generics
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.filters import BaseFilterBackend
from apps.home.models import (
Server,
ContentFilter,
MessageMutator,
MessageStyle,
Subscription,
Content,
SubscriptionRecommendation
)
from apps.authentication.models import DiscordUser, ServerMember
from apps.home.models import SubChannel, Filter, Subscription, SavedGuilds, TrackedContent, ArticleMutator, GuildSettings
from apps.authentication.models import DiscordUser
from .metadata import ExpandedMetadata
from .serializers import (
ServerSerializer,
ContentFilterSerializer,
MessageMutatorSerializer,
MessageStyleSerializer,
SubscriptionSerializer,
ContentSerializer,
SubscriptionRecommendationSerializer
SubChannelSerializer,
FilterSerializer,
SubscriptionSerializer_GET,
SubscriptionSerializer_POST,
SavedGuildSerializer,
TrackedContentSerializer_GET,
TrackedContentSerializer_POST,
ArticleMutatorSerializer,
GuildSettingsSerializer
)
from .permissions import HasServerAccess
from .errors import NotAMemberError
log = logging.getLogger(__name__)
@ -41,7 +33,6 @@ class DefaultPagination(PageNumberPagination):
"""Default class for pagination in API views."""
page_size = 10
page_query_param = "page"
page_size_query_param = "page_size"
max_page_size = 25
@ -50,269 +41,580 @@ def is_automated_admin(user):
return user.user_type == DiscordUser.USER_TYPES.AUTOMATED_USER and user.is_superuser
class ListView(generics.ListAPIView):
# =================================================================================================
# 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
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
pagination_class = DefaultPagination
metadata_class = ExpandedMetadata
serializer_class = SubChannelSerializer
queryset = SubChannel.objects.all().order_by("id")
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
class ListCreateView(generics.ListCreateAPIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
pagination_class = DefaultPagination
metadata_class = ExpandedMetadata
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
class DetailView(generics.RetrieveAPIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
class ChangableDetailView(generics.RetrieveUpdateDestroyAPIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
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
filterset_fields = ["id", "channel_id", "channel_name", "subscription"]
search_fields = ["channel_name"]
def get_queryset(self):
if self.request.user.is_superuser:
return Server.objects.all().order_by("id")
return SubChannel.objects.all()
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return Server.objects.filter(id__in=servers).order_by("id")
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 Server_DetailView(DeletableDetailView):
serializer_class = ServerSerializer
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 Server.objects.all()
return SubChannel.objects.all()
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return Server.objects.filter(id__in=servers)
saved_guilds = SavedGuilds.objects.filter(added_by=self.request.user)
guild_ids = [guild.guild_id for guild in saved_guilds]
def destroy(self, request, *args, **kwargs):
server = self.get_object()
return SubChannel.objects.filter(subscription__guild_id__in=guild_ids)
if not self.request.user.is_superuser:
member = ServerMember.objects.get(server=server, user=request.user)
if not member.is_owner:
# =================================================================================================
# 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
"""
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": "Only the owner can destroy server data."},
{"detail": "You are forbidden from viewing this subscription"},
status=status.HTTP_403_FORBIDDEN
)
return super().destroy(request, *args, **kwargs)
subscription = subscriptions.filter(id=kwargs["pk"]).first()
if not subscription:
return Response(
{"detail": "not found"},
status=status.HTTP_404_NOT_FOUND
)
# region Filters
channels = SubChannel.objects.filter(subscription=subscription)
channels.delete();
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
return Response(status=status.HTTP_204_NO_CONTENT)
def get_queryset(self):
if self.request.user.is_superuser:
return ContentFilter.objects.all().order_by("name")
# =================================================================================================
# SavedGuild Views
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):
if self.request.user.is_superuser:
return ContentFilter.objects.all()
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):
if self.request.user.is_superuser:
return MessageStyle.objects.all().order_by("id")
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return MessageStyle.objects.filter(server__in=servers).order_by("id")
class MessageStyle_DetailView(ChangableDetailView):
serializer_class = MessageStyleSerializer
def get_queryset(self):
if self.request.user.is_superuser:
return MessageStyle.objects.all()
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return MessageStyle.objects.filter(server__in=servers)
# region Subscriptions
class Subscription_ListView(ListCreateView):
filterset_fields = ("id", "server", "name", "url", "created_at", "updated_at", "extra_notes", "active", "publish_threshold", "filters", "message_style")
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):
if self.request.user.is_superuser:
return Subscription.objects.all().order_by("id")
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return Subscription.objects.filter(server__in=servers).order_by("id")
class Subscription_DetailView(ChangableDetailView):
serializer_class = SubscriptionSerializer
def get_queryset(self):
if self.request.user.is_superuser:
return Subscription.objects.all()
servers = ServerMember.objects.filter(user=self.request.user).values_list("server", flat=True)
return Subscription.objects.filter(server__in=servers)
# region Content
class ContentFilterBackend(BaseFilterBackend):
class SavedGuild_ListView(generics.ListCreateAPIView):
"""
If `match_any=true` in querystring, matches 'any' params instead
of 'all' params.
View to provide a list of SavedGuild model instances.
Can also be used to create a new instance.
Supports: GET, POST
"""
_MATCH_ANY_PARAM = "match_any"
_IGNORE_PARAMS = [
DefaultPagination.page_query_param,
DefaultPagination.page_size_query_param,
"search",
"subscription"
]
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
def filter_queryset(self, request, queryset, view):
filters = Q()
match_any = request.query_params.get(self._MATCH_ANY_PARAM, "").lower() == "true"
log.debug(f"matching any against content: {match_any}")
pagination_class = None
serializer_class = SavedGuildSerializer
metadata_class = ExpandedMetadata
for param, value in request.query_params.items():
if param in self._IGNORE_PARAMS or param == self._MATCH_ANY_PARAM:
continue
query = Q(**{param: value})
if match_any:
filters |= query
else:
filters &= query
log.debug(query)
queryset_filter = queryset.filter(filters)
log.debug(queryset_filter.query)
return queryset_filter
class Content_ListView(ListCreateView):
search_fields = (
"item_id",
"item_guid",
"item_url",
"item_title",
"item_description",
"item_content_hash",
"item_image_url",
"item_thumbnail_url",
"item_published",
"item_author",
"item_author_url",
"item_feed_title",
"item_feed_url"
)
filterset_fields = search_fields + ("id", "subscription", "subscription__server")
ordering_fields = filterset_fields
serializer_class = ContentSerializer
filter_backends = [
ContentFilterBackend,
filters.SearchFilter,
rest_filters.DjangoFilterBackend,
filters.OrderingFilter
]
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 Content.objects.all().order_by("-subscription__created_at", "id")
return SavedGuilds.objects.all()
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")
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 Content_DetailView(ChangableDetailView):
serializer_class = ContentSerializer
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 Content.objects.all()
return SavedGuilds.objects.all()
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)
return SavedGuilds.objects.filter(added_by=self.request.user)
# region Sub Recommendations
# =================================================================================================
# GuildSettings Views
class SubscriptionRecommendations_ListView(ListView):
queryset = SubscriptionRecommendation.objects.all().order_by("id")
serializer_class = SubscriptionRecommendationSerializer
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
"""
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
"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
serializer_class = ArticleMutatorSerializer
queryset = ArticleMutator.objects.all().order_by("id")

View File

@ -2,7 +2,7 @@
from django.contrib import admin
from .models import DiscordUser, ServerMember
from .models import DiscordUser
@admin.register(DiscordUser)
@ -10,8 +10,3 @@ 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,8 +1,6 @@
# Generated by Django 5.0.4 on 2024-09-24 14:05
# Generated by Django 5.0.4 on 2024-05-03 13:14
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
@ -12,7 +10,6 @@ class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('home', '0001_initial'),
]
operations = [
@ -29,8 +26,6 @@ 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')),
@ -42,14 +37,4 @@ 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

@ -0,0 +1,20 @@
# 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

@ -0,0 +1,19 @@
# 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,25 +159,3 @@ 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

@ -3,12 +3,17 @@
from django.urls import path
from django.contrib.auth.views import LogoutView
from .views import DiscordLoginAction, DiscordLoginRedirect, Login
from .views import DiscordLoginAction, DiscordLoginRedirect, Login, GuildsView, GuildChannelsView, SaveGuildView
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("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")
]

View File

@ -49,8 +49,7 @@ class DiscordLoginRedirect(View):
discord_user = authenticate(request, discord_user_data=raw_user_data)
login(request, discord_user)
redirect_url = request.GET.get("redirect") or "home:index"
return redirect(redirect_url)
return redirect("home:index")
def exchange_code(self, code: str) -> dict:
"""
@ -119,6 +118,34 @@ 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):
@ -130,4 +157,9 @@ class GuildChannelsView(View):
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
)
return JsonResponse(response.json(), status=response.status_code, safe=False)
return JsonResponse(response.json(), safe=False)
class SaveGuildView(View):
pass

View File

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

View File

@ -1,7 +1,8 @@
# Generated by Django 5.0.4 on 2024-09-24 14:05
# Generated by Django 5.0.4 on 2024-06-17 01:11
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
@ -10,70 +11,53 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
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',
name='Filter',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=32)),
('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')),
('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)),
],
options={
'verbose_name': 'filter',
'verbose_name_plural': 'filters',
'get_latest_by': 'id',
},
),
migrations.CreateModel(
name='BotLogicLogs',
name='SavedGuilds',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('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')),
('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?")),
],
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',
@ -81,26 +65,76 @@ class Migration(migrations.Migration):
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=32)),
('url', models.URLField()),
('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)),
('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)),
('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='Content',
name='TrackedContent',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('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')),
('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)),
],
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

@ -0,0 +1,21 @@
# 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

@ -1,47 +0,0 @@
# 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

@ -1,67 +0,0 @@
# 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

@ -0,0 +1,37 @@
# 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

@ -1,19 +0,0 @@
# 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

@ -0,0 +1,17 @@
# 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

@ -1,26 +0,0 @@
# 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

@ -0,0 +1,18 @@
# 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

@ -0,0 +1,23 @@
# 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

@ -1,22 +0,0 @@
# 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

@ -1,20 +0,0 @@
# 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

@ -0,0 +1,27 @@
# 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

@ -1,19 +0,0 @@
# 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

@ -0,0 +1,24 @@
# 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

@ -1,32 +0,0 @@
# 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

@ -0,0 +1,18 @@
# 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

@ -0,0 +1,23 @@
# 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

@ -1,19 +0,0 @@
# 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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
# 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

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

View File

@ -0,0 +1,24 @@
# 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,57 +0,0 @@
# Generated by Django 5.0.4 on 2024-11-01 23:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0015_alter_subscription_message_style'),
]
operations = [
migrations.AddField(
model_name='content',
name='item_author',
field=models.CharField(default='', max_length=256),
preserve_default=False,
),
migrations.AddField(
model_name='content',
name='item_author_url',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='content',
name='item_description',
field=models.CharField(default='', max_length=1024),
preserve_default=False,
),
migrations.AddField(
model_name='content',
name='item_feed_title',
field=models.CharField(default='', max_length=1024),
preserve_default=False,
),
migrations.AddField(
model_name='content',
name='item_feed_url',
field=models.URLField(default=''),
preserve_default=False,
),
migrations.AddField(
model_name='content',
name='item_image_url',
field=models.URLField(blank=True, null=True),
),
migrations.AddField(
model_name='content',
name='item_published',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='content',
name='item_thumbnail_url',
field=models.URLField(blank=True, null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.4 on 2024-11-01 23:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0016_content_item_author_content_item_author_url_and_more'),
]
operations = [
migrations.AddField(
model_name='content',
name='blocked',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,19 @@
# 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,18 +0,0 @@
# Generated by Django 5.0.4 on 2024-11-01 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0017_content_blocked'),
]
operations = [
migrations.AlterField(
model_name='content',
name='item_author',
field=models.CharField(blank=True, max_length=256, null=True),
),
]

View File

@ -0,0 +1,19 @@
# 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

@ -0,0 +1,18 @@
# 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,29 +0,0 @@
# Generated by Django 5.0.4 on 2024-11-13 00:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0018_alter_content_item_author'),
]
operations = [
migrations.CreateModel(
name='SubscriptionRecommendation',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=32)),
('description', models.CharField(max_length=250)),
('url', models.URLField()),
],
),
migrations.RemoveField(
model_name='subscription',
name='unique_rules',
),
migrations.DeleteModel(
name='UniqueContentRule',
),
]

View File

@ -1,47 +0,0 @@
# Generated by Django 5.0.4 on 2024-11-13 00:59
from django.db import migrations
def add_subscription_recommendations(apps, schema_editor):
model = apps.get_model("home", "SubscriptionRecommendation")
model.objects.create(
name="BBC News",
description="BBC News - News Front Page.",
url="http://feeds.bbci.co.uk/news/rss.xml"
)
model.objects.create(
name="BBC News · Entertainment",
description="BBC News - Culture.",
url="https://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml"
)
model.objects.create(
name="BBC News · Technology",
description="BBC News - Technology.",
url="https://feeds.bbci.co.uk/news/technology/rss.xml"
)
model.objects.create(
name="Sky News",
description="The Latest News from the UK and Around the World.",
url="https://feeds.skynews.com/feeds/rss/home.xml"
)
model.objects.create(
name="Sky News · Strange News",
description="Weird News - Strange and Odd News Stories.",
url="https://feeds.skynews.com/feeds/rss/strange.xml"
)
model.objects.create(
name="Free Games Finder",
description="Free Games Finder's mission is to find and raise awareness of free games.",
url="https://steamcommunity.com/groups/freegamesfinders/rss/"
)
class Migration(migrations.Migration):
dependencies = [
('home', '0019_subscriptionrecommendation_and_more'),
]
operations = [
migrations.RunPython(add_subscription_recommendations)
]

View File

@ -0,0 +1,21 @@
# 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

@ -0,0 +1,122 @@
# 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

@ -0,0 +1,22 @@
# 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,55 +1,179 @@
# -*- 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.exceptions import ValidationError
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.validators import MaxValueValidator, MinValueValidator
log = logging.getLogger(__name__)
# region Server
class Server(models.Model):
class GuildSettings(models.Model):
"""
Represents a Discord Server.
Instances of this model are automatically handled, and manual intervension
should be avoided if possible.
"""
id = models.PositiveBigIntegerField(primary_key=True)
name = models.CharField(max_length=128)
icon_hash = models.CharField(max_length=128, blank=True, null=True)
is_bot_operational = models.BooleanField(default=None, null=True)
active = models.BooleanField(default=True)
class Meta:
verbose_name = "server"
verbose_name_plural = "servers"
get_latest_by = "name"
@property
def icon_url(self):
return f"https://cdn.discordapp.com/icons/{self.id}/{self.icon_hash}.webp?size=80"
def __str__(self):
return self.name
# region Content Filter
class ContentFilter(models.Model):
"""
Filters for the content produced by Subscriptions.
Owned by the related server.
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.
"""
id = models.AutoField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE)
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")
)
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
@property
def settings(self):
return GuildSettings.objects.get(guild_id=self.guild_id)
def save(self, *args, **kwargs):
GuildSettings.objects.get_or_create(guild_id=self.guild_id)
super().save(*args, **kwargs)
class SubChannel(models.Model):
"""
Represents a Discord TextChannel, saved against a Subscription.
SubChannels are used as targets to send content from Subscriptions.
"""
id = models.AutoField(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
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
@ -65,239 +189,196 @@ class ContentFilter(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")),
)
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()
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)
class Meta:
verbose_name = "filter"
verbose_name_plural = "filters"
get_latest_by = "id"
ordering = ("name",)
constraints = [
models.UniqueConstraint(
fields=["name", "guild_id"],
name="unique name & guild id pair"
)
]
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
# region Message Mutator
class TrackedContent(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.
"""
class MessageMutator(models.Model):
"""
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)
class Meta:
verbose_name = "message mutator"
verbose_name_plural = "message mutators"
get_latest_by = "id"
def __str__(self):
def __str__(self) -> str:
return self.name
# region Message Style
class MessageStyle(models.Model):
"""
Custom styles to be applied via the Bot.
Owned by the related server.
"""
id = models.AutoField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, null=True, blank=False)
name = models.CharField(max_length=32)
colour = models.CharField(max_length=6, default="3498db")
is_embed = models.BooleanField()
is_hyperlinked = models.BooleanField() # title only
show_author = models.BooleanField()
show_timestamp = models.BooleanField()
show_images = models.BooleanField()
fetch_images = models.BooleanField() # if not included with RSS item
title_mutator = models.ForeignKey(
to=MessageMutator,
related_name="title_mutated_messagestyle",
on_delete=models.SET_NULL,
null=True,
blank=True
)
description_mutator = models.ForeignKey(
to=MessageMutator,
related_name="desc_mutated_messagestyle",
on_delete=models.SET_NULL,
null=True,
blank=True
)
auto_created = models.BooleanField(default=False, blank=True)
class Meta:
verbose_name = "message style"
verbose_name_plural = "message styles"
get_latest_by = "id"
def delete(self, *args, **kwargs):
if self.auto_created:
raise ValidationError("Cannot delete 'MessageStyle' instance with 'auto_created=True'")
# If this style is being used, reset the users to the default style
default_message_style = MessageStyle.objects.get(server=self.server, auto_created=True)
Subscription.objects \
.filter(server=self.server, message_style=self) \
.update(message_style=default_message_style)
super().delete(*args, **kwargs)
def __str__(self):
return self.name
@receiver(post_save, sender=Server)
def create_default_items(sender, instance, created, **kwargs):
if not created:
return
# Create a default message style, so the user can get straight into creating subscriptions
# (subscriptions require a message style to exist)
MessageStyle.objects.create(
server=instance,
name=_("Default Message Style"),
colour="3498db",
is_embed=True,
is_hyperlinked=True,
show_author=True,
show_timestamp=True,
show_images=True,
fetch_images=True,
title_mutator=None,
description_mutator=None,
auto_created=True
)
# region Discord Channel
class DiscordChannel(models.Model):
"""
Store limited data on a relevant Channel from Discord, used to indicate
where subscriptions should send content to. Instance creation & deletion
is handled internally, when Subscriptions are modified.
"""
id = models.PositiveBigIntegerField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, blank=False)
name = models.CharField(max_length=128)
is_nsfw = models.BooleanField()
def __str__(self):
return f"#{self.name}"
# region Subscription
class Subscription(models.Model):
"""
These represent RSSFeeds, storing relevant settings for managing them.
Owned by the related server.
"""
id = models.AutoField(primary_key=True)
server = models.ForeignKey(to=Server, on_delete=models.CASCADE, blank=False)
name = models.CharField(max_length=32, blank=False)
url = models.URLField()
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(default=timezone.now)
extra_notes = models.CharField(max_length=250, default="", blank=True)
active = models.BooleanField(default=True)
publish_threshold = models.DateTimeField(default=timezone.now)
channels = models.ManyToManyField(to=DiscordChannel, related_name="subscriptions", blank=False)
filters = models.ManyToManyField(to=ContentFilter, blank=True)
message_style = models.ForeignKey(to=MessageStyle, on_delete=models.SET_NULL, null=True, blank=False)
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_description = models.CharField(max_length=1024)
item_content_hash = models.CharField(max_length=1024)
item_image_url = models.URLField(null=True, blank=True)
item_thumbnail_url = models.URLField(null=True, blank=True)
item_published = models.DateField(null=True, blank=True)
item_author = models.CharField(max_length=256, null=True, blank=True)
item_author_url = models.URLField(null=True, blank=True)
item_feed_title = models.CharField(max_length=1024)
item_feed_url = models.URLField()
blocked = models.BooleanField(default=False)
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}"
# Subscription Recommendations
class SubscriptionRecommendation(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=32)
description = models.CharField(max_length=250)
url = models.URLField()
def __str__(self):
return self.url

View File

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

View File

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

View File

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

@ -1,124 +0,0 @@
const contentTableId = "#contentTable";
// region Init Module
function initContentModule() {
initializeDataTable(
contentTableId,
[
{
title: "Subscription",
data: "subscription",
className: "col-1",
render: () => renderLinkToSubscription()
},
{
title: "Item ID",
data: "item_id",
className: "col-2"
},
{
title: "Item GUID",
data: "item_guid",
className: "col-2"
},
{
title: "Title",
data: "item_title",
className: "col-2"
},
{
title: "URL",
data: "item_url",
className: "col-2"
},
{
title: "Content Hash",
data: "item_content_hash",
className: "col-2"
},
{
title: "Blocked",
data: "blocked",
className: "col-1 text-center",
render: renderBooleanColumn
}
]
);
}
// 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");
}
// region Delete Data
getTableFiltersComponent(contentTableId).find(".js-tableDeleteBtn").on("click", async function() {
const rows = getSelectedTableRows(contentTableId);
const names = rows.map(row => row.item_title)
const isMany = names.length > 1;
const deleteContent = () => {
rows.forEach(async row => {
await ajaxRequest(`/api/content/${row.id}/`, "DELETE");
})
setTimeout(() => { $(contentTableId).trigger("doDataLoad") }, 600);
}
createModal({
title: `Delete ${isMany ? "Many Contents" : "Content"}`,
texts: [
{
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "this"} content${isMany ? "s" : ""}?</p>`,
html: true
},
{
content: arrayToHtmlList(names, true).prop("outerHTML"),
html: true
}
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteContent
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
});
// region Open Content Sub
$(contentTableId).on("click", ".js-openContentSub", async event => {
const contentTable = $(contentTableId).DataTable();
const row = contentTable.row($(event.currentTarget).closest("tr"));
const subscriptionId = row.data().subscription;
$("#subscriptionsTab").click();
await openDataModal(subModalId, subscriptionId, `/api/subscriptions/${subscriptionId}/`);
});

View File

@ -1,221 +0,0 @@
const filterTableId = "#filterTable";
const filterModalId = "#filterFormModal";
// region Init Module
function initFiltersModule() {
initializeDataTable(
filterTableId,
[
{
title: "Name",
data: "name",
className: "col-4",
render: renderEditColumn
},
{
title: "Match",
data: "match",
className: "col-4"
},
{
title: "Algorithm",
data: "matching_algorithm",
className: "col-2",
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: "col-1 text-center",
render: data => renderBooleanColumn(!data)
},
{
title: "Type",
data: "is_whitelist",
className: "col-1",
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 Table Share Btn
// getTableFiltersComponent(filterTableId).filter(".js-tableShareBtn").on("click", async () => {
// createModal({
// title: "Share"
// })
// });
// region Table Delete Btns
$(filterModalId).find(".modal-del-btn").on("click", async function() {
$(filterModalId).modal("hide");
const id = parseInt($(filterModalId).data("primary-key"));
const filter = $(filterTableId).DataTable().row((idx, row) => { return row.id === id }).data();
const name = sanitise(filter.name);
const deleteFilter = async () => {
await ajaxRequest(`/api/filters/${filter.id}/`, "DELETE");
setTimeout(async () => {
$(filterTableId).trigger("doDataLoad");
await loadSubModalOptions(
$(subModalId).find('[data-field="filters"]'),
`/api/filters/?server=${selectedServer.id}`
);
}, 600);
}
createModal({
title: "Delete a Content Filter",
texts: [
{
content: `<span>Do you wish to permanently delete <b>${name}</b>?</span>`,
html: true
},
{ content: "This action is irreversible, you will lose this filter forever." }
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteFilter,
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true,
onClick: async () => $(filterModalId).modal("show")
}
]
});
});
getTableFiltersComponent(filterTableId).find(".js-tableDeleteBtn").on("click", async function() {
const rows = getSelectedTableRows(filterTableId);
const isMany = rows.length > 1;
const names = rows.map(row => row.name);
const deleteFilters = async () => {
rows.forEach(async row => {
await ajaxRequest(`/api/filters/${row.id}/`, "DELETE");
});
setTimeout(async () => {
$(filterTableId).trigger("doDataLoad");
await loadSubModalOptions(
$(subModalId).find('[data-field="filters"]'),
`/api/filters/?server=${selectedServer.id}`
);
}, 600);
}
createModal({
title: `Delete ${isMany ? "Many Content Filters" : "Content Filter"}`,
texts: [
{
content: `<p>Do you wish to permanently delete ${isMany ? "these" : "these"} content filter${isMany ? "s" : ""}?</p>`,
html: true
},
{
content: arrayToHtmlList(names, true).prop("outerHTML"),
html: true
}
],
buttons: [
{
className: "btn-danger me-3",
iconClass: "bi-trash3",
closeModal: true,
onClick: deleteFilters
},
{
className: "btn-secondary px-4",
iconClass: "bi-arrow-return-right",
closeModal: true
}
]
});
});
// region New/Edit Modal
$(filterTableId).closest('.js-tableBody').siblings('.js-tableFilters').on("click", ".js-tableAddBtn", 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 $input = $(filterModalId).find('[data-field="matching_algorithm"]');
const data = await ajaxRequest("/api/filters/", "OPTIONS");
data.actions.GET.matching_algorithm.choices.forEach(algorithm => {
$input.append($(
"<option>",
{
text: algorithm.display_name,
value: algorithm.value > 0 ? algorithm.value : ""
}
));
});
if ($input.next('.corbz-select-container').length) {
$input.next('.corbz-select-container').remove();
$input.initCorbzSelect();
}
}

View File

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

View File

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

View File

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

View File

@ -1,214 +0,0 @@
@import "bootstrap.scss";
@import "./sidebar.scss";
@import "./tables.scss";
/* widths */
.mw-10rem {
max-width: 10rem;
}
.col-switch-width {
width: 3.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
}
/* Server Tabs */
#serverTabs .nav-item .nav-link:hover:not(.active) {
background-color: var(--bs-secondary-bg);
}
#serverTabs .nav-item .nav-link:not(.active) {
color: var(--bs-text-body);
}
.act-as-link {
color: rgba(var(--bs-link-color-rgb), 1);
text-decoration: none;
&:hover { color: var(--bs-link-hover-color-rgb); }
&:disabled, &.disabled { color: var(--bs-secondary-color) }
}
// Custom Select Component
.corbz-select.is-invalid +.corbz-select-container {
border-color: var(--bs-form-invalid-border-color);
.corbz-select-dropdown {
border-top: 0;
border-color: var(--bs-form-invalid-border-color);
}
}
.corbz-select-container {
position: relative;
display: block;
appearance: none;
font-size: 1rem;
line-height: 1.5;
font-weight: 400;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
background-clip: padding-box;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm);
outline: 0 !important;
&.active {
border-radius: var(--bs-border-radius-sm) var(--bs-border-radius-sm) 0 0;
.corbz-select-selected::after {
transform: translateY(-50%) rotate(180deg);
}
}
.corbz-select-selected {
height: 100%;
width: 100%;
max-width: 100%;
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.375rem 0.75rem;
padding-right: calc(1.5em + 0.75rem);
cursor: pointer;
// Arrow icon
&::after {
content: "\f282";
font-family: "bootstrap-icons";
font-weight: normal;
font-size: 0.7rem;
position: absolute;
right: 0.9rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
}
.corbz-select-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
width: calc(100% + 2.25px);
transform: translateX(-1px);
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0 0 var(--bs-border-radius-sm) var(--bs-border-radius-sm);
box-shadow: var(--bs-box-shadow);
z-index: 1000;
outline: 0;
.corbz-select-search {
width: 100%;
padding: 0.375rem 0.75rem;
background: none;
border: none;
border-bottom: 1px solid var(--bs-border-color);
outline: none;
}
.corbz-select-text {
padding: 0.375rem 0.75rem;
color: var(--bs-secondary-color);
@extend small;
}
.corbz-select-dropdown-options {
max-height: 200px;
overflow-y: auto;
.corbz-select-option {
padding: 0.375rem 0.75rem;
cursor: pointer;
outline: 0;
&:hover, &:focus { background-color: var(--bs-tertiary-bg); }
&.active {
color: var(--bs-white);
background-color: var(--bs-primary);
}
// Multi-select versions
> .corbz-option-checkbox {
--bs-form-check-bg-image: none;
width: 1rem;
height: 1rem;
appearance: none;
background-color: var(--bs-body-bg);
background-image: var(--bs-form-check-bg-image);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm);
vertical-align: middle;
outline: 0;
&:checked {
--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
&:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
}
> span {
display: inline-block;
vertical-align: middle;
margin-left: 0.75rem;
}
}
}
}
}

View File

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

View File

@ -1,168 +0,0 @@
// Table Search Bar
.table-search-group {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: stretch;
width: 100%;
max-width: 450px;
height: fit-content;
border: 1px solid var(--bs-border-color);
border-radius: 0.25rem;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
.table-search-label {
display: flex;
align-items: center;
padding: 0.375rem 0.75rem;
padding-right: 0;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: inherit;
background-color: transparent;
border: none;
text-align: center;
white-space: nowrap;
}
.table-search-input {
position: relative;
flex: 1 1 auto;
padding: 0.375rem 0.75rem;
appearance: none;
width: 1%;
min-width: 0;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
border: none;
border-radius: 0;
color: inherit;
background-color: transparent;
background-clip: padding-box;
// Disable bootstrap's focus highlights
&:focus {
outline: 0;
box-shadow: none;
}
}
.table-search-button {
@extend .btn;
color: inherit;
background-color: transparent;
border: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
border-left: 1px solid var(--bs-border-color) !important;
border-right: 1px solid var(--bs-border-color) !important;
margin-right: -1px;
&:focus, &:hover { background-color: var(--bs-tertiary-bg); }
&:active, &.active { background-color: var(--bs-secondary); }
&:disabled, &.disabled {
color: var(--bs-tertiary-color);
background-color: var(--bs-secondary-bg);
}
}
}
// Badge popover
.table-badge-popover .popover-body {
display: flex;
flex-wrap: wrap;
padding: 1rem 0.5rem 0.5rem 1rem;
max-width: 200px;
}
// Table Button Controls
.table-button-controls > div {
margin-left: 1rem;
&:first-of-type { margin-left: 0 !important; }
@include media-breakpoint-down(md) { margin-left: 0.25rem; }
}
// Table
.table {
td, th {
vertical-align: middle;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
// Top & bottom border colours
border-color: var(--bs-border-color);
tr:first-child > *,
tr:last-child > * {
border-color: inherit !important;
}
tr {
height: 50px;
// Selected rows
&.selected {
> * {
color: var(--bs-body-color) !important;
box-shadow: inset 0 0 0 9999px rgba(var(--bs-secondary-bg-rgb), 0.9) !important;
}
a {
color: var(--bs-link-color) !important;
}
}
}
thead { }
tbody { }
tfoot { }
.col-hex-icon {
margin: 0 auto;
width: 25px;
height: 25px;
border-radius: 0.25rem;
}
}

View File

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

View File

@ -1,67 +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 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="corbz-select" 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>
<span class="ms-2 d-none d-sm-inline">Save</span>
</button>
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3 px-3 px-sm-4" data-bs-dismiss="modal" tabindex="8">
<i class="bi bi-arrow-return-right"></i>
</button>
</div>
</form>
</div>
</div>
</div>

View File

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

View File

@ -1,136 +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 needs-validation" novalidate>
<div class="modal-header border-bottom-0">
<h5 class="modal-title ms-2">
<span class="form-create">Add</span>
<span class="form-edit">Edit</span>
Subscription
</h5>
</div>
<div class="modal-body p-4">
<input type="hidden" data-role="is-id">
<div class="row">
<div class="col-lg-6 pe-lg-4">
<div class="mb-4">
<label for="subName" class="form-label">Name</label>
<input type="text" id="subName" name="subName" class="form-control rounded-1" required data-field="name">
<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">
<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="corbz-select" data-field="message_style" data-default="firstOption">
</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">
<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="corbz-select" multiple data-field="channels" tabindex="5"></select>
<div class="form-text">Send content to these channels.</div>
</div>
</div>
<div class="col-lg-6 ps-lg-4">
<div class="mb-4">
<label for="subFilters" class="form-label">Filters</label>
<select name="subFilters" id="subFilters" class="corbz-select" multiple data-field="filters" tabindex="6"></select>
<div class="form-text">Filter out unwanted content.</div>
</div>
</div>
<div class="col-lg-6 pe-lg-4">
<div class="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" data-default="true">
<div class="form-text">Disabled Subscriptions will be ignored when processing content.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer px-4 border-top-0">
<button type="button" class="btn btn-outline-secondary rounded-1 me-auto ms-0 form-create" data-bs-toggle="modal" data-bs-target="#subRecommendationModal">
<i class="bi bi-lightbulb"></i>
<span class="ms-2 d-none d-sm-inline">Recommend</span>
</button>
<button type="button" class="btn btn-danger rounded-1 me-3 ms-0 modal-del-btn form-edit">
<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">
<i class="bi bi-archive"></i>
</button>
<button type="submit" class="btn btn-primary rounded-1 me-0 px-4">
<i class="bi bi-floppy"></i>
<span class="ms-2 d-none d-sm-inline">Save</span>
</button>
<button type="button" class="btn btn-secondary rounded-1 me-0 ms-3 px-3 px-sm-4" data-bs-dismiss="modal">
<i class="bi bi-arrow-return-right"></i>
</button>
</div>
</form>
</div>
</div>
</div>
<div id="subRecommendationModal" 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 Recommendations</span>
</h5>
</div>
<div class="modal-body p-4">
<div class="container-fluid">
<div class="row g-4 js-subscription-recommendations-group"></div>
</div>
</div>
<div class="modal-footer px-4 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>
<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 px-4 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

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

View File

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

View File

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

View File

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

View File

@ -1,74 +0,0 @@
<div class="js-tableFilters row mt-4 mb-3 px-sm-3">
<div class="col-md-6 col-lg-5 col-xl-4 d-flex">
<div class="table-search-group mb-md-0 mb-3 dropdown">
<label for="searchForStyle" class="table-search-label">
<i class="bi bi-search"></i>
</label>
<input type="search" id="searchForStyle" class="table-search-input" placeholder="search">
<button type="button" class="table-search-button dropdown-toggle" disabled data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end mt-1">
<li>
<h6 class="dropdown-header">Sort By</h6>
</li>
<li>
<h6 class="dropdown-header">Filter By</h6>
</li>
</ul>
</div>
</div>
<div class="col-md-6 col-lg-7 col-xl-8 text-md-end table-button-controls">
<div class="d-inline-block">
<button type="button" class="js-tableAddBtn btn btn-primary rounded-1">
<i class="bi bi-plus-lg"></i>
<span class="ms-2">Create</span>
</button>
</div>
<div class="d-inline-block">
<button type="button" class="js-tableRefreshBtn btn btn-outline-secondary rounded-1 disable-while-loading">
<i class="bi bi-arrow-clockwise"></i>
<span class="ms-2">Refresh</span>
</button>
</div>
<div class="d-inline-block">
<button type="button" class="js-tableDeleteBtn btn btn-danger rounded-1" disabled>
<i class="bi bi-trash3"></i>
<span class="ms-2">Trash</span>
</button>
</div>
</div>
</div>
<div class="js-tableBody table-responsive my-3 px-sm-3">
<table id="styleTable" class="table table-hover"></table>
</div>
<div class="js-tableControls row px-sm-3 mb-4">
<div class="col-lg-6">
<nav class="table-pagination mb-4 mb-lg-0 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="styleTablePageSizer" class="form-label align-self-center mb-0 me-2">Show</label>
<select name="styleTablePageSizer" id="styleTablePageSizer" class="corbz-select table-page-sizer disable-while-loading">
<option value="1">1</option>
<option value="10" selected>10</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="25">25</option>
</select>
<span class="ms-2">of&nbsp;</span>
<span class="pageinfo-total text-nowrap">10</span>
</div>
</div>

View File

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

View File

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

View File

@ -74,35 +74,4 @@
.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);
}
}

View File

@ -0,0 +1,85 @@
@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,10 +1,5 @@
/* Select 2 */
/* Keep the select2 options visible above modals */
.select2-container--open {
z-index: 9999;
}
.select2-selection {
background-color: var(--bs-body-bg) !important;
font-size: 1rem !important;
@ -91,16 +86,4 @@
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm);
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
/* Invalid Select2 */
select.select-2.is-invalid + .select2 .select2-selection {
color: var(--bs-form-invalid-color);
padding-right: calc(1.5em + 0.75rem) !important;
border-color: var(--bs-form-invalid-border-color) !important;
background-repeat: no-repeat;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
background-position: right calc(0.375em + 0.1875rem) center;
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
}
}

View File

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 434 KiB

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