This commit is contained in:
Corban-Lee Jones 2024-03-31 20:25:44 +01:00
commit 8e5498c191
19 changed files with 465 additions and 159 deletions

View File

@ -147,4 +147,4 @@ class UserServerLinkSerializer(DynamicModelSerializer):
class Meta:
model = UserServerLink
fields = ("id", "server_id", "user", "name", "permissions")
fields = ("id", "server_id", "user", "name", "icon", "icon_url", "permissions")

View File

@ -51,7 +51,7 @@ class Subscription_ListView(generics.ListCreateAPIView):
filter_backends = [filters.SearchFilter, rest_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["uuid", "name", "rss_url", "server", "targets", "creation_datetime", "extra_notes", "active"]
search_fields = ["name", "extra_notes"]
ordering_fields = ["creation_datetime"]
ordering_fields = ["creation_datetime", "server"]
def post(self, request):
serializer = self.get_serializer(data=request.data)

View File

@ -14,4 +14,4 @@ class DiscordUserAdmin(admin.ModelAdmin):
@admin.register(UserServerLink)
class UserServerLink(admin.ModelAdmin):
list_display = ["id", "user", "name", "permissions"]
list_display = ["id", "user", "name", "permissions", "icon"]

View File

@ -1,7 +1,8 @@
# Generated by Django 5.0.1 on 2024-03-11 17:38
# Generated by Django 5.0.1 on 2024-03-27 11:13
import apps.authentication.managers
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
@ -30,8 +31,16 @@ class Migration(migrations.Migration):
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_superuser', models.BooleanField(default=False, help_text='Designates whether the user has unrestricted site control.', verbose_name='superuser status')),
],
managers=[
('objects', apps.authentication.managers.DiscordUserOAuth2Manager()),
),
migrations.CreateModel(
name='UserServerLink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('server_id', models.PositiveBigIntegerField()),
('name', models.CharField(max_length=64)),
('permissions', models.IntegerField()),
('icon', models.CharField(help_text="the guild's icon hash", max_length=64, verbose_name='icon')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.1 on 2024-03-13 13:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentication', '0001_initial'),
]
operations = [
migrations.AlterModelManagers(
name='discorduser',
managers=[
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.1 on 2024-03-27 11:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='userserverlink',
name='icon',
field=models.CharField(blank=True, help_text="the guild's icon hash", max_length=64, null=True, verbose_name='icon'),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 5.0.1 on 2024-03-17 22:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0002_alter_discorduser_managers'),
]
operations = [
migrations.CreateModel(
name='UserServerLink',
fields=[
('id', models.PositiveBigIntegerField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('perm_flags', models.IntegerField()),
('user_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.1 on 2024-03-17 23:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0003_userserverlink'),
]
operations = [
migrations.AlterField(
model_name='userserverlink',
name='perm_flags',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.1 on 2024-03-20 10:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentication', '0004_alter_userserverlink_perm_flags'),
]
operations = [
migrations.RenameField(
model_name='userserverlink',
old_name='perm_flags',
new_name='permissions',
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.1 on 2024-03-20 10:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentication', '0005_rename_perm_flags_userserverlink_permissions'),
]
operations = [
migrations.RenameField(
model_name='userserverlink',
old_name='user_id',
new_name='user',
),
]

View File

@ -1,30 +0,0 @@
# Generated by Django 5.0.1 on 2024-03-20 11:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0006_rename_user_id_userserverlink_user'),
]
operations = [
migrations.AddField(
model_name='userserverlink',
name='server_id',
field=models.PositiveBigIntegerField(default=1),
preserve_default=False,
),
migrations.AlterField(
model_name='userserverlink',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='userserverlink',
name='permissions',
field=models.IntegerField(default=1),
preserve_default=False,
),
]

View File

@ -135,10 +135,25 @@ class UserServerLink(models.Model):
user = models.ForeignKey(to=DiscordUser, on_delete=models.CASCADE)
name = models.CharField(max_length=64)
permissions = models.IntegerField()
icon = models.CharField(
_("icon"),
max_length=64,
help_text=_("the guild's icon hash"),
null=True,
blank=True
)
def __str__(self):
return self.name
@property
def icon_url(self):
if not self.icon: # TODO: review this
return "/static/images/placeholder-100x100.png"
return f"https://cdn.discordapp.com/icons/{self.server_id}/{self.icon}.webp?size=80"
# @property
# def is_admin(self):
# return self.perm_flags & 0x0000000000000008

View File

@ -29,6 +29,9 @@ class DiscordLoginRedirect(View):
This method will "login" the user.
"""
if request.GET.get("error"):
return redirect("auth:login")
code = request.GET.get("code")
access_token = self.exchange_code_for_token(code)
raw_user_data = self.get_raw_user_data(access_token)
@ -54,7 +57,7 @@ class DiscordLoginRedirect(View):
# Fetch the access token
response = requests.post(
url="https://discord.com/api/oauth2/token",
url=f"{settings.DISCORD_API_URL}/oauth2/token",
data=request_data["data"],
headers=request_data["headers"]
)
@ -70,7 +73,7 @@ class DiscordLoginRedirect(View):
"""
response = requests.get(
url="https://discord.com/api/v6/users/@me",
url=f"{settings.DISCORD_API_URL}/users/@me",
headers={"Authorization": f"Bearer {access_token}"}
)
@ -89,7 +92,7 @@ class GuildsView(View):
def get(self, request, *args, **kwargs):
response = requests.get(
url="https://discord.com/api/v6/users/@me/guilds",
url=f"{settings.DISCORD_API_URL}/users/@me/guilds",
headers={"Authorization": f"Bearer {request.user.access_token}"}
)
@ -114,7 +117,8 @@ class GuildsView(View):
server_id=server["id"],
user=user,
name=server["name"],
permissions=server["permissions"]
permissions=server["permissions"],
icon=server["icon"]
)
for server in content
]
@ -132,7 +136,7 @@ class GuildChannelsView(View):
log.debug("fetching channels from %s using token: %s", guild_id, settings.BOT_TOKEN)
response = requests.get(
url=f"https://discord.com/api/v10/guilds/{guild_id}/channels",
url=f"{settings.DISCORD_API_URL}/guilds/{guild_id}/channels",
headers={"Authorization": f"Bot {settings.BOT_TOKEN}"}
)

View File

@ -242,4 +242,12 @@ body {
.toast .progress-bar {
border-radius: 0;
}
}
/* Progress Bar */
@keyframes decreaseProgressWidth {
from { width: 100%; }
to { width: 0%; }
}

View File

@ -35,6 +35,24 @@ function getSubscription(uuid) {
});
}
function getServer(serverId) {
return new Promise(function(resolve, reject) {
$.ajax({
url: `/api/serverlink/?server_id=${serverId}`,
type: "GET",
beforeSend: function(xhr) {
xhr.setRequestHeader("X-CSRFToken", CSRF_MiddlewareToken);
},
success: function(response) {
resolve(response.results[0]);
},
error: function(response) {
reject(response.results[0]);
}
});
});
}
function newSubscription(formData) {
return new Promise(function(resolve, reject) {
$.ajax({

View File

@ -1,5 +1,5 @@
const toastTemplate = `
<div class="toast mt-3" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast mt-3" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast-header">
<i class="toast-icon bi me-2"></i>
<strong class="toast-title me-auto">Bootstrap</strong>
@ -12,8 +12,6 @@ const toastTemplate = `
</div>
`
const toastFadeDelayMs = 5000;
const toastTypes = {
"primary": "bi-bell-fill",
"info": "bi-info-circle-fill",
@ -22,24 +20,28 @@ const toastTypes = {
"success": "bi-check-circle-fill"
}
function showToast(typeName, title, message) {
function animateToastProgress(progressBar, durationMs) {
progressBar.css({
"animation-duration": (durationMs / 1000) + "s",
"animation-name": "decreaseProgressWidth",
"animation-fill-mode": "forwards",
"animation-timing-function": "linear"
});
}
function showToast(typeName, title, message, duration=5000) {
var toast = makeToast(typeName, title, message)
$(".toasts-container").prepend(toast);
$(".toasts-container").append(toast);
toast.toast("show", {autohide: false});
toast.toast("show");
// var progressPercent = 100;
// var progressBar = toast.find(".progress-bar");
var progressBar = toast.find(".progress-bar");
progressBar.on("animationend", function() {
toast.toast("hide");
setTimeout(function() {toast.remove()}, 1500);
});
// const toastProgressInterval = setInterval(function() {
// progressPercent -= 1;
// progressBar.css("width", progressPercent + "%");
// if (progressPercent < 0) {
// // toast.toast("hide");
// // setTimeout(function() {toast.remove(); }, 1500);
// clearInterval(toastProgressInterval);
// }
// }, (toastFadeDelayMs + 1500) / 100);
animateToastProgress(progressBar, duration);
}
function makeToast(typeName, title, message) {
@ -47,7 +49,7 @@ function makeToast(typeName, title, message) {
var template = $(toastTemplate);
template.find(".toast-icon").addClass(`text-${typeName}`).addClass(iconClass);
template.find(".progress-bar").addClass(`bg-${typeName}`);
template.find(".toast-body").text(message);
template.find(".toast-body").html(message);
template.find(".toast-title").text(title);
return template;

View File

@ -83,6 +83,7 @@
<label for="editSubChannels">Channels</label>
<select name="editSubChannels" id="editSubChannels" class="border-3 bd select-2" data-dropdownparent="#subEditModal" required multiple>
</select>
<div class="invalid-feedback">Please Select one or more Channels.</div>
</div>
</div>
<div id="navExtrasPanel" class="tab-pane fade" role="tabpanel" aria-labelledby="navExtrasTab" tabindex="0">
@ -143,6 +144,28 @@
<!-- Specific Page JS goes HERE -->
{% block javascripts %}
<script id="subCategoryTemplate" type="text/template">
<div class="col-12">
<hr>
<div class="peers mB-20">
<div class="peer">
<img class="cat-icon rounded-3" width="50" height="50">
</div>
<div class="peer px-2">
<div class="layers align-items-start">
<h5 class="layer cat-name mb-1"></h4>
<h6 class="layer cat-id"></h6>
</div>
</div>
</div>
<div class="container-fluid">
<div class="row sub-container">
</div>
</div>
</div>
</script>
<script id="subItemTemplate" type="text/template">
<div class="col-md-6 col-lg-4 col-xxl-3">
<div class="sub-item layers bd bg-body h-100 rounded-3" data-uuid="">
@ -170,7 +193,7 @@
</div>
</div>
<div class="peer">
<button type="button" class="btn bg-body-tertiary waves-effect bd rounded-3 border-0" onclick="showToast('warning', 'Warning', 'not implemented');">
<button type="button" class="btn bg-body-tertiary waves-effect bd rounded-3 border-0" onclick="showToast('warning', 'Warning', 'Activity History not implemented yet');">
<i class="bi bi-clock-history"></i>
</button>
</div>
@ -182,6 +205,11 @@
<i class="bi bi-pencil"></i>
</button>
</div>
<div class="peer ms-3">
<button type="button" class="sub-copy btn bg-body-tertiary waves-effect bd rounded-3 border-0">
<i class="bi bi-copy"></i>
</button>
</div>
<div class="peer ms-3">
<button type="button" class="sub-delete btn bg-body-tertiary waves-effect bd rounded-3 border-0">
<i class="bi bi-trash3"></i>
@ -194,4 +222,325 @@
</script>
<script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/subscriptions.js' %}"></script>
<script type="text/javascript">
function createSubCategory(serverId) {
var template = $($("#subCategoryTemplate").html());
template.find(".cat-id").text(serverId);
var server = getServer(serverId).then(resp => {
template.find(".cat-icon").attr("src", resp.icon_url);
template.find(".cat-name").text(resp.name);
});
return template
}
function createSubscriptionItem(data) {
var template = $($("#subItemTemplate").html());
// Store the uuid for later reference
template.find(".sub-item").attr("data-uuid", data.uuid);
// Display data
template.find(".sub-name").text(data.name);
template.find(".sub-uuid").text(data.uuid);
template.find(".sub-rss").text(data.rss_url).attr("href", data.rss_url);
template.find(".sub-img").attr("src", data.image);
template.find(".sub-channel-count").text(data.targets.split(";").length);
// Display Sub Description
if (!data.extra_notes) {
template.find(".sub-desc").hide();
} else {
template.find(".sub-desc").text(data.extra_notes);
}
// Display formatted datetime
var displayDate = new Date(data.creation_datetime).toISOString().slice(0, 10);
template.find(".sub-datetime").text(displayDate);
// Provide button functionality
template.find(".sub-edit").attr("onclick", `subEditModal("${data.uuid}");`);
template.find(".sub-delete").attr("onclick", `confirmUnsubscribe("${data.uuid}");`);
// Enable tooltips
template.find('[data-bs-toggle="tooltip"]').tooltip();
// Make the switch toggle the active flag
template.find(".sub-active").prop("checked", data.active).change(function() {
var checkbox = $(this);
checkbox.prop("disabled", true);
var isChecked = checkbox.prop("checked");
var activeText = isChecked === true ? "active" : "inactive";
var formData = new FormData();
formData.append("active", isChecked);
patchSubscription(data.uuid, formData).then(function(resp) {
showToast("success", "Subscription Modified", `<b>${data.name}</b> is now <b>${activeText}</b>.`);
checkbox.prop("disabled", false);
})
});
return template
}
$(document).ready(function() {
loadGuilds();
loadSubscriptions();
});
function loadGuilds() {
$.ajax({
url: "/guilds",
type: "GET",
success: function(response) {
// Add each guild as a selectable option
for (i = 1; i < response.length; i++) {
var guild = response[i];
var option = $("<option>", {text: guild.name, value: guild.id})
$("#editSubServer").append(option);
}
// Bind the select to update channels on change
$("#editSubServer").change(function() {
var selectedGuildId = $(this).find("option:selected").attr("value");
loadChannels(selectedGuildId);
});
},
error: function(response) {
console.error(JSON.stringify(response, null, 4));
showToast("danger", `Error Loading Guilds`, "Couldn't load user guilds. Try refreshing the page.", 15000);
}
});
}
function processChannelsResponseError(response) {
switch (response.code) {
// 50001:
// Forbidden response
case 50001:
showToast(
"danger",
`Discord API Error: ${response.code}`,
`PYRSS Bot is lacking forbidden from fetching channels for this server.
Ensure that at least one condition is true:
<ul>
<li>The server is a community server.</li>
<li>PYRSS Bot is a member of the server.</li>
</ul>
`,
10000
);
break;
default:
showToast("danger", `Discord API Error: ${response.code}`, response.message, 10000);
break;
}
}
function loadChannels(guildID) {
$("#editSubChannels").empty();
$("#editSubChannels").val(null);
$.ajax({
url: `/channels?guild=${guildID}`,
type: "GET",
success: function(response) {
// Validate the response, if property "code" exists, there is an error.
// A valid response only returns a list.
if (response.hasOwnProperty("code")) {
processChannelsResponseError(response);
return;
}
if (response.hasOwnProperty("code") && response.code === 50001) {
showToast(
"danger", "Unable to fetch channels",
"PYRSS Bot is lacking permissions to fetch the channels from this server, ensure one of two conditions is met: <br><ul><li>The server is a community server</li><li>PYRSS Bot is a member of the server</li></ul>",
10000
);
return;
}
for (i = 1; i < response.length; i++) {
var channel = response[i];
if (channel.type !== 0) {
continue
}
var selectedChannelIDs;
try {
selectedChannelIDs = $("#editSubChannels").attr("data-current").split(";");
}
catch {
selectedChannelIDs = [];
}
$("#editSubChannels").append($("<option>", {
value: channel.id,
text: "#" + channel.name,
selected: selectedChannelIDs.includes(channel.id.toString())
}));
}
},
error: function(response) {
alert(JSON.stringify(response, null, 4));
// showToast("danger", `Error Loading Guilds`, "Couldn't load user guilds. Try refreshing the page.", 15000);
if (response.code == "50001") {
alert("PYRSS Bot is Missing Access to this Server");
}
else {
alert("unknown error fetching channels " + response.code)
}
}
});
}
function updateSubscriptionCount(difference, overwrite) {
const beforeChange = overwrite ? 0 : Number($(".subs-count").text());
$(".subs-count").text(beforeChange + difference);
}
function loadSubscriptions() {
$("#subscriptionContainer").empty();
getSubscriptions(ordering="server").then(resp => {
updateSubscriptionCount(resp.results.length, true);
var categorisedSubs = {};
$.each(resp.results, function(index, sub) {
categorisedSubs[sub.server] = categorisedSubs[sub.server] || [];
categorisedSubs[sub.server].push(sub);
});
console.log(JSON.stringify(categorisedSubs, null, 4))
$.each(categorisedSubs, function(server, subs) {
var categoryElem = createSubCategory(server);
$("#subscriptionContainer").append(categoryElem);
$.each(subs, function(index, sub) {
var subElem = createSubscriptionItem(sub);
categoryElem.find(".sub-container").append(subElem);
});
})
// for (i = 0; i < resp.results.length; i++) {
// var sub = resp.results[i];
// console.log(JSON.stringify(sub));
// var subElem = createSubscriptionItem(sub);
// }
});
}
function confirmUnsubscribe(uuid) {
var title = $(`.sub-item[data-uuid='${uuid}'] .sub-name`).text();
$(".del-sub-name").text(title);
$(".del-sub-uuid").text(uuid);
$(".del-sub-confirm").attr("onclick", `unsubscribe("${uuid}")`)
$("#subDeleteModal").modal("show");
}
function unsubscribe(uuid) {
var subElem = $(`#subscriptionContainer .sub-item[data-uuid="${uuid}"]`);
subElem.find("button").prop("disabled", true);
var subName = subElem.find(".sub-name").text();
deleteSubscription(uuid).then(resp => {
subElem.parent().remove();
updateSubscriptionCount(-1);
$("#subDeleteModal").modal("hide");
showToast(
"success",
"Deleted Subscription",
`Successfully deleted <b>${subName}</b>.`
);
});
}
function subEditModal(uuid) {
var modal = $("#subEditModal");
modal.find("input").val(null);
modal.find("textarea").val(null);
modal.find("select").val("");
$("#editSubChannels").empty();
$("#subEditForm").removeClass("was-validated");
$("#navDetailsTab").click();
if (uuid === -1) {
modal.find(".modal-title").text("New Subscription");
}
else {
modal.find(".modal-title").text("Edit Subscription");
getSubscription(uuid).then(resp => {
// alert(JSON.stringify(resp, null, 4));
$("#editSubName").val(resp.name);
$("#editSubURL").val(resp.rss_url);
$("#editSubServer").val(String(resp.server)).trigger("change");
$("#editSubChannels").attr("data-current", resp.targets);
$("#editSubNotes").val(resp.extra_notes);
});
}
$("#subEditModal").attr("data-uuid", uuid);
$("#subEditModal").modal("show");
}
function submitSubEditModal() {
// Validation
var form = $("#subEditForm");
if (!form[0].checkValidity()) {
form.addClass("was-validated");
return;
}
const uuid = $("#subEditModal").attr("data-uuid");
const subName = $("#editSubName").val();
var formData = new FormData();
formData.append("uuid", uuid);
formData.append("name", subName);
formData.append("rss_url", $("#editSubURL").val());
formData.append("server", $("#editSubServer").val());
formData.append("extra_notes", $("#editSubNotes").val());
formData.append("active", true);
var selectedTargets = $("#editSubChannels option:selected").toArray().map(item => item.value).join(';');
formData.append("targets", selectedTargets);
var imageFile = $("#editSubImage")[0].files[0];
if (imageFile) {
formData.append("image", imageFile);
}
if (uuid === "-1") {
newSubscription(formData).then(resp => {
loadSubscriptions();
$("#subEditModal").modal("hide");
showToast("success", "Subscription Created", `<b>${subName}</b> successfully created.`);
})
}
else {
editSubscription(uuid, formData).then(resp => {
loadSubscriptions();
$("#subEditModal").modal("hide");
showToast("success", "Subscription Modified", `<b>${subName}</b> successfully modified.`);
});
}
}
</script>
{% endblock javascripts %}

View File

@ -27,7 +27,15 @@
</div>
</div>
</div>
<ul class="sidebar-menu">
<li class="nav-item">
<a class="sidebar-link" href="/">
<span class="icon-holder">
<i class="c-blue-500 ti-home"></i>
</span>
<span class="title">Subscriptions</span>
</a>
</li>
<!-- ### $Sidebar Menu ### -->
<!-- <li class="nav-item">
<hr class="bgc-grey-500">

View File

@ -140,6 +140,7 @@ DISCORD_CODE_EXCHANGE_REQUEST = {
"scope": " ".join(DISCORD_SCOPES)
}
}
DISCORD_API_URL = env("DISCORD_API_URL")
DISCORD_OAUTH2_URL = env("DISCORD_OAUTH2_URL")
# Logging