Refactoring: new multi app structure

This commit is contained in:
2023-08-15 03:13:07 +09:00
parent 30b3efa5fc
commit e45d1af857
94 changed files with 634 additions and 1548 deletions

View File

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.account'
verbose_name = 'Auth and account management'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,40 @@
from django.conf import settings
from django.urls import path
from django.contrib.auth.views import LogoutView
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
TokenVerifyView
)
app_name = 'account'
urlpatterns = [
# WEB LOGOUT:
path(
'account/logout/',
LogoutView.as_view(next_page=settings.LOGOUT_REDIRECT_URL),
name='logout'
)
]
urlpatterns += [
# JWT AUTH:
path(
'api/v1/auth/token/',
TokenObtainPairView.as_view(),
name='token_obtain_pair'
),
path(
'api/v1/auth/token/refresh/',
TokenRefreshView.as_view(),
name='token_refresh'
),
path(
'api/v1/auth/token/verify/',
TokenVerifyView.as_view(),
name='token_verify'
),
]

View File

@@ -0,0 +1,46 @@
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect
from django.shortcuts import resolve_url
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme # renamed Dj^3.*
from two_factor.admin import AdminSiteOTPRequired, AdminSiteOTPRequiredMixin
# https://stackoverflow.com/questions/48600737/django-two-factor-auth-cant-access-admin-site
class AdminSiteOTPRequiredMixinRedirectSetup(AdminSiteOTPRequired):
"""
Fixes the current implementation of django-two-factor-auth = 1.15.3
when admin page is patched for 2fa
(circular redirect - super user created with manage.py
and cannot log in because he does not have a device configured).
The class redirects to the setup page.
After that, you can log in as usual.
"""
def login(self, request, extra_context=None):
redirect_to = request.POST.get(
REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME)
)
# For users not yet verified the AdminSiteOTPRequired.has_permission
# will fail. So use the standard admin has_permission check:
# (is_active and is_staff) and then check for verification.
# Go to index if they pass, otherwise make them setup OTP device.
if request.method == "GET" and super(
AdminSiteOTPRequiredMixin, self
).has_permission(request):
# Already logged-in and verified by OTP
if request.user.is_verified():
# User has permission
index_path = reverse("admin:index", current_app=self.name)
else:
# User has permission but no OTP set:
index_path = reverse("two_factor:setup", current_app=self.name)
return HttpResponseRedirect(index_path)
if not redirect_to or not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[request.get_host()]
):
redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)
return redirect_to_login(redirect_to)

View File

View File

@@ -0,0 +1,25 @@
from django.contrib import admin
from .models import Platform, Archive, Ticket
# Register your models here.
class PlatformAdmin(admin.ModelAdmin):
pass
class TicketAdmin(admin.ModelAdmin):
pass
class ArchiveAdmin(admin.ModelAdmin):
pass
class TokenAdmin(admin.ModelAdmin):
pass
admin.site.register(Platform, PlatformAdmin)
admin.site.register(Ticket, TicketAdmin)
admin.site.register(Archive, ArchiveAdmin)

View File

@@ -0,0 +1,39 @@
from django_filters.rest_framework import (
CharFilter,
FilterSet,
NumberFilter,
)
from django_filters import widgets
from apps.collector.models import Archive, Ticket
from .utils import DateTimeFilterMixin
class ArchiveFilter(DateTimeFilterMixin, FilterSet):
class Meta:
model = Archive
fields = {
'id': ['exact', 'in', 'lte', 'gte'],
'ticket': ['exact', 'in', 'lte', 'gte'],
'time_create': ['exact', 'lte', 'gte']
}
class TicketFilter(DateTimeFilterMixin, FilterSet):
number = NumberFilter(
field_name='number',
widget=widgets.CSVWidget(),
)
user = CharFilter(
field_name='user__username'
)
class Meta:
model = Ticket
fields = {
'id': ['exact', 'in', 'lte', 'gte'],
'number': ['exact', 'contains', 'in', 'lte', 'gte'],
'resolved': ['exact'],
'user': ['exact']
}

View File

@@ -0,0 +1,13 @@
from rest_framework import permissions
class IsGuestUpload(permissions.BasePermission):
"""
Special permission class for the ability to upload attachments
to an unauthorized user using a ticket token
"""
def has_permission(self, request, view):
if request.method in ('HEAD', 'OPTIONS', 'POST',):
return True
return request.user.is_authenticated

View File

@@ -0,0 +1,60 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.openapi import OpenApiTypes
from apps.collector.models import Archive, Platform, Ticket
@extend_schema_field(OpenApiTypes.NUMBER)
class TimestampField(serializers.Field):
def to_representation(self, value) -> int:
return value.timestamp()
@extend_schema_field(OpenApiTypes.NUMBER)
class JsTimestampField(serializers.Field):
def to_representation(self, value) -> int:
return round(value.timestamp()*1000)
class PublicArchiveUploadSerializer(serializers.ModelSerializer):
class Meta:
model = Archive
fields = ['file', 'ticket']
class ArchiveSerializer(serializers.ModelSerializer):
time_create = JsTimestampField(read_only=True)
class Meta:
model = Archive
fields = ['id', 'file', 'ticket', 'time_create']
class PlatformSerializer(serializers.ModelSerializer):
class Meta:
model = Platform
fields = ['id', 'name', 'pretty_name']
class TicketSerializer(serializers.ModelSerializer):
time_create = JsTimestampField(read_only=True)
time_update = JsTimestampField(read_only=True)
token = serializers.UUIDField(read_only=True)
user = serializers.ReadOnlyField(source='user.username')
class Meta:
model = Ticket
fields = [
'id',
'number',
'resolved',
'token',
'attempts',
'platform',
'time_create',
'time_update',
'user'
]

View File

@@ -0,0 +1,21 @@
from django.urls import path, include
from rest_framework import routers
from . import views
# ▄▀█ █▀█ █
# █▀█ █▀▀ █
# -- -- --
app_name = 'collector_api'
router = routers.DefaultRouter()
router.register(r'archives', views.ArchiveViewSet)
router.register(r'platforms', views.PlatformViewSet)
router.register(r'tickets', views.TicketViewSet)
urlpatterns = [
# CRUD:
path('v1/', include(router.urls)),
]

View File

@@ -0,0 +1,20 @@
from django_filters import NumberFilter
class DateTimeFilterMixin:
year__gte = NumberFilter(
field_name='time_create',
lookup_expr='year__gte'
)
year__lte = NumberFilter(
field_name='time_create',
lookup_expr='year__lte'
)
month__gte = NumberFilter(
field_name='time_create',
lookup_expr='month__gte'
)
month__lte = NumberFilter(
field_name='time_create',
lookup_expr='month__lte'
)

View File

@@ -0,0 +1,94 @@
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from rest_framework import status
# from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import viewsets
from rest_framework import filters
from django_filters.rest_framework import DjangoFilterBackend
from apps.collector.models import Archive, Ticket, Platform # ???????
from .filters import ArchiveFilter, TicketFilter
from .permissions import IsGuestUpload
from .serializers import (
PublicArchiveUploadSerializer,
ArchiveSerializer,
PlatformSerializer,
TicketSerializer
)
class ArchiveViewSet(viewsets.ModelViewSet):
queryset = Archive.objects.order_by('-time_create')
serializer_class = ArchiveSerializer
parser_classes = (MultiPartParser, FormParser)
permission_classes = (IsGuestUpload, )
filter_backends = [DjangoFilterBackend]
filterset_class = ArchiveFilter
def create(self, request, *args, **kwargs):
# ! upload-token protection:
upload_token = request.headers.get('upload-token', '')
if not request.user.is_authenticated and upload_token:
try:
bound_ticket = Ticket.objects.get(token=upload_token)
if bound_ticket.resolved:
return Response(
{'error': f'ticket {upload_token} already resolved'},
status=status.HTTP_423_LOCKED
)
if bound_ticket.attempts <= 0:
return Response(
{'error': f'token {upload_token} expired'},
status=status.HTTP_423_LOCKED
)
bound_ticket.attempts -= 1
bound_ticket.save()
# ? mixin bound ticket number to request.data from user
request.data['ticket'] = bound_ticket.number
# ? change serializer for guest user
self.serializer_class = PublicArchiveUploadSerializer
except (ValidationError, ObjectDoesNotExist,):
return Response(
{'error': f'token {upload_token} is not valid'},
status=status.HTTP_403_FORBIDDEN
)
elif not request.user.is_authenticated:
return Response(
{'error': 'Header Upload-Token is required'},
status=status.HTTP_401_UNAUTHORIZED
)
# ! default create method:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(
serializer.data,
status=status.HTTP_201_CREATED,
headers=headers
)
class PlatformViewSet(viewsets.ModelViewSet):
queryset = Platform.objects.all()
lookup_field = 'name'
serializer_class = PlatformSerializer
permission_classes = (IsAuthenticated, )
class TicketViewSet(viewsets.ModelViewSet):
queryset = Ticket.objects.order_by('-time_create')
lookup_field = 'number'
serializer_class = TicketSerializer
permission_classes = (IsAuthenticated, )
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
filterset_class = TicketFilter
search_fields = ['number']
def perform_create(self, serializer):
serializer.save(user=self.request.user)

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CollectorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.collector'
verbose_name = 'Collector archives for analyse'

View File

@@ -0,0 +1,31 @@
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, Div
from crispy_bootstrap5.bootstrap5 import FloatingField
from .models import Ticket
class TicketForm(forms.ModelForm):
class Meta:
model = Ticket
fields = ['number', 'attempts', 'platform', 'note']
widgets = {
'platform': forms.RadioSelect()
}
def __init__(self, *args, **kwargs):
super(TicketForm, self).__init__(*args, **kwargs)
self.helper = FormHelper(self)
# self.helper.attrs = {"novalidate": ''}
self.helper.layout = Layout(
Div(
FloatingField('number', 'attempts'),
'platform',
css_class='col-lg-2'
),
Div('note', css_class='col-lg-6'),
Submit('submit', 'Save', css_class='btn btn-primary'),
)

View File

@@ -0,0 +1,59 @@
# Generated by Django 4.2 on 2023-08-14 09:07
import apps.collector.utils
from django.conf import settings
import django.core.files.storage
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import pathlib
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Platform',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True)),
('pretty_name', models.CharField(max_length=20)),
],
),
migrations.CreateModel(
name='Ticket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.IntegerField(db_index=True, unique=True)),
('resolved', models.BooleanField(default=False)),
('note', models.TextField(blank=True)),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('attempts', models.IntegerField(default=5, validators=[django.core.validators.MaxValueValidator(10), django.core.validators.MinValueValidator(0)])),
('time_create', models.DateTimeField(auto_now_add=True)),
('time_update', models.DateTimeField(auto_now=True)),
('platform', models.ForeignKey(db_column='platform_name', on_delete=django.db.models.deletion.CASCADE, to='collector.platform', to_field='name')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-time_create'],
},
),
migrations.CreateModel(
name='Archive',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(blank=True, null=True, storage=django.core.files.storage.FileSystemStorage(base_url='/archives/', location=pathlib.PurePosixPath('/home/stepan/Documents/Dev/ISPsystem/logs-collector/logs_collector/archives')), upload_to=apps.collector.utils.logs_dir_path)),
('md5', models.CharField(editable=False, max_length=1024)),
('time_create', models.DateTimeField(auto_now_add=True)),
('time_update', models.DateTimeField(auto_now=True)),
('ticket', models.ForeignKey(db_column='ticket_number', on_delete=django.db.models.deletion.CASCADE, to='collector.ticket', to_field='number')),
],
),
]

View File

@@ -0,0 +1,100 @@
import uuid
import hashlib
from functools import partial
from django.core.validators import MaxValueValidator, MinValueValidator
from django.contrib.auth.models import User
from django.db import models
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.urls import reverse
from .utils import logs_dir_path
# Create a custom storage location, using a value from your settings file
sensitive_upload_storage = FileSystemStorage(
location=settings.MEDIA_ROOT_FOR_SENSITIVE_FILES,
base_url=settings.MEDIA_URL_FOR_SENSITIVE_FILES
)
# ... and a file field that will use the custom storage
AuthenticatedFileField = partial(
models.FileField,
storage=sensitive_upload_storage
)
class Archive(models.Model):
file = AuthenticatedFileField(
upload_to=logs_dir_path,
blank=True,
null=True
)
md5 = models.CharField(max_length=1024, editable=False)
time_create = models.DateTimeField(auto_now_add=True)
time_update = models.DateTimeField(auto_now=True)
ticket = models.ForeignKey(
'Ticket',
to_field='number',
db_column='ticket_number',
on_delete=models.CASCADE
)
def save(self, *args, **kwargs):
# calculate md5 hash sum and write md5 field to db
with self.file.open('rb') as f:
md5 = hashlib.md5()
for byte_block in iter(lambda: f.read(4096), b""):
md5.update(byte_block)
self.md5 = md5.hexdigest()
# Call the "real" save() method
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('collector:download', kwargs={'path': self.file})
def __str__(self):
return str(self.file)
class Platform(models.Model):
name = models.CharField(max_length=20, unique=True)
pretty_name = models.CharField(max_length=20)
def get_absolute_url(self):
return reverse('collector:platform', kwargs={'platform': self.name})
def __str__(self):
return self.pretty_name
class Ticket(models.Model):
number = models.IntegerField(unique=True, db_index=True)
resolved = models.BooleanField(default=False)
note = models.TextField(blank=True)
token = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
attempts = models.IntegerField(default=5, validators=[
MaxValueValidator(10),
MinValueValidator(0)
])
time_create = models.DateTimeField(auto_now_add=True)
time_update = models.DateTimeField(auto_now=True)
platform = models.ForeignKey(
'Platform',
to_field='name',
db_column='platform_name',
on_delete=models.CASCADE
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
ordering = ['-time_create']
def get_absolute_url(self):
return reverse(
'collector:ticket',
kwargs={'platform': self.platform.name, 'ticket': self.number}
)
def __str__(self):
return str(self.number)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,90 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
;(() => {
"use strict"
const getStoredTheme = () => localStorage.getItem("theme")
const setStoredTheme = (theme) => localStorage.setItem("theme", theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
}
const setTheme = (theme) => {
if (
theme === "auto" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
document.documentElement.setAttribute("data-bs-theme", "dark")
} else {
document.documentElement.setAttribute("data-bs-theme", theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector("#bd-theme")
if (!themeSwitcher) {
return
}
const themeSwitcherText = document.querySelector("#bd-theme-text")
const activeThemeIcon = document.querySelector(".theme-icon-active use")
const btnToActive = document.querySelector(
`[data-bs-theme-value="${theme}"]`
)
const svgOfActiveBtn = btnToActive
.querySelector("svg use")
.getAttribute("href")
document.querySelectorAll("[data-bs-theme-value]").forEach((element) => {
element.classList.remove("active")
element.setAttribute("aria-pressed", "false")
})
btnToActive.classList.add("active")
btnToActive.setAttribute("aria-pressed", "true")
activeThemeIcon.setAttribute("href", svgOfActiveBtn)
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
themeSwitcher.setAttribute("aria-label", themeSwitcherLabel)
if (focus) {
themeSwitcher.focus()
}
}
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
const storedTheme = getStoredTheme()
if (storedTheme !== "light" && storedTheme !== "dark") {
setTheme(getPreferredTheme())
}
})
window.addEventListener("DOMContentLoaded", () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll("[data-bs-theme-value]").forEach((toggle) => {
toggle.addEventListener("click", () => {
const theme = toggle.getAttribute("data-bs-theme-value")
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()

View File

@@ -0,0 +1,6 @@
const tooltipTriggerList = document.querySelectorAll(
'[data-bs-toggle="tooltip"]'
)
const tooltipList = [...tooltipTriggerList].map(
(tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl)
)

View File

@@ -0,0 +1,108 @@
$(function () {
console.log("JQ is ready to work");
// CSRF token:
const CSRF = $("input[name=csrfmiddlewaretoken]").val()
// -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
// delete one attachment
// -- -- -- -- -- -- --
$(".btn-archive-eraser").click(function (e) {
e.preventDefault();
const archiveListElement = $(this).attr("data-jq-archive-target");
const delUrl = $(this).attr("href");
$.ajax({
type: "DELETE",
url: delUrl,
headers: {
"X-CSRFToken":CSRF,
"Content-Type":"application/json"
},
// beforeSend: function(xhr) {
// xhr.setRequestHeader("X-CSRFToken", csrf);
// },
success: function (data, textStatus, jqXHR) {
console.log(jqXHR.status);
$(archiveListElement).hide(1500);
},
error: function (data, textStatus, jqXHR) {
console.log(jqXHR.status);
}
});
});
// change ticket state
// -- -- -- -- -- -- --
$("input[name=ticket-state]").click(function () {
console.log('Press');
let resolved = false;
let ticketStateUrl = $(this).attr("ticket-state-url")
if ($(this).attr("ticket-state-switch") === "1") {
$(this).attr("ticket-state-switch", "0"); // disable
} else {
resolved = true;
$(this).attr("ticket-state-switch", "1"); // enable
}
$.ajax({
type: "PATCH",
url: ticketStateUrl,
headers: {
"X-CSRFToken":CSRF,
"Content-Type":"application/json"
},
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({
resolved: resolved,
}),
success: function (data, textStatus, jqXHR) {
console.log(jqXHR.status)
},
error: function (data, textStatus, jqXHR) {
console.log(data)
console.log(jqXHR.status)
}
});
});
// delete ticket with attachments:
// -- -- -- -- -- -- -- -- -- -- --
$(".btn-ticket-del").click(function (e) {
e.preventDefault();
const delUrl = $(this).attr("href")
const redirectUrl = $(this).attr("data-jq-ticket-del-redirect")
const elementTarget = $(this).attr("data-jq-ticket-del-target")
const delDiv = $(elementTarget)
$.ajax({
type: "DELETE",
url: delUrl,
headers: {
'X-CSRFToken':CSRF,
'Content-Type':'application/json'
},
success: function (data, textStatus, jqXHR) {
console.log(jqXHR.status);
if (delDiv.length) {
delDiv.hide(1500);
} else {
window.location.href = redirectUrl;
}
},
error: function (data, textStatus, jqXHR) {
console.log(jqXHR.status);
}
});
});
// copy token to clipboard:
// -- -- -- -- -- -- -- --
$(".token-clipboard").click(function (e) {
e.preventDefault();
const btn = $(this)
const tokenInput = btn.siblings("input[name=ticket-token]").val();
const icon = btn.children(":first").get(0)
navigator.clipboard.writeText(tokenInput);
btn.html('<i class="bi bi-check-lg"></i>')
// Revert button label after 500 milliseconds
setTimeout(function(){
btn.html(icon);
}, 500)
});
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% load static %}
{% block collector_head %}
<title>{% block title %}{% endblock title %}</title>
{% endblock collector_head %}
{% block collector_content %}
<header>
<section>
{% include 'includes/navigation.html' %}
</section>
</header>
<main>
<section>
{% block main %}{% endblock main %}
</section>
</main>
<footer>
<section>
{% block footer %}{% endblock footer %}
</section>
</footer>
{% endblock collector_content %}
{% block collector_scripts %}
<script src="{% static 'collector/js/jquery-3.7.0.min.js' %}"></script>
{% block bs %}{% endblock bs %}
{% block jquery %}{% endblock jquery %}
{% endblock collector_scripts %}

View File

@@ -0,0 +1,43 @@
<div
class="modal fade"
id="modal-archive-del-{{ archive.id }}"
tabindex="-1"
aria-labelledby="LabelArchive-{{ archive.id }}"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5
class="modal-title"
id="LabelArchive-{{ archive.id }}">Delete this file?
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close">
</button>
</div>
<div class="modal-body">
<p style="word-wrap: break-word">{{ archive.file }}</p>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>Cancel
</button>
<a
href="{% url 'collector_api:archive-detail' archive.id %}"
type="button"
class="btn btn-danger btn-archive-eraser"
data-bs-dismiss="modal"
data-jq-archive-target="#li-archive-{{ archive.id }}"
>Delete
</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<div
class="modal fade"
id="modal-ticket-del-{{ ticket.number }}"
tabindex="-1"
aria-labelledby="LabelTicket"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="LabelTicket">
Delete ticket #{{ ticket.number }} ?
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<p>Deleting a ticket will also permanently delete all files associated with it.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<a
type="button"
href="{% url 'collector_api:ticket-detail' ticket.number %}"
class="btn btn-danger btn-ticket-del"
data-bs-dismiss="modal"
data-jq-ticket-del-target="#div-ticket-{{ ticket.number }}"
data-jq-ticket-del-redirect="{% url 'collector:tickets' %}"
>Delete</a
>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,41 @@
{% if page_obj.has_other_pages %}
<div class="row">
<nav class="d-flex justify-content-center mt-3" aria-label="...">
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item">
<a
class="page-link"
href="?page={{ page_obj.previous_page_number }}"
>Back
</a>
</li>
{% else %}
<li class="page-item disabled"><a class="page-link">Back</a></li>
{% endif %}
{% for page in paginator.page_range %}
{% if page_obj.number == page %}
<li class="page-item active" aria-current="page">
<button class="page-link">{{ page }}</button>
</li>
{% elif page >= page_obj.number|add:-2 and page <= page_obj.number|add:2%}
<li class="page-item">
<a class="page-link" href="?page={{ page }}">{{ page }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a
class="page-link"
href="?page={{ page_obj.next_page_number }}"
>Next
</a>
</li>
{% else %}
<li class="page-item disabled"><a class="page-link">Next</a></li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}

View File

@@ -0,0 +1,38 @@
{% load collector_extras %}
<li
id="li-archive-{{ archive.id }}"
class="list-group-item list-group-item-action">
<smal>
<b>File:</b>
<span style="word-wrap: break-word">{{ archive.file.name|clean_filename }}</span>
</small>
<small>
<br>
<b>MD5:</b>
<span style="word-wrap: break-word">{{ archive.md5 }}</span>
</small>
<small>
<br>
<b>Uploaded:</b>
<span style="word-wrap: break-word">{{ archive.time_update|date:"D d.m.y H:i" }}</span>
</small>
<br>
<small>
<b>Size:</b>
<span style="word-wrap: break-word">{{ archive.file.size|sizify }}</span>
</small>
<div class="row">
<div class="d-flex justify-content-sm-start justify-content-between" >
<a
class="btn btn-outline-success btn-sm mt-2"
href="{{ archive.get_absolute_url }}"
><i class="bi bi-download"></i> GET</a>
<button
button type="button"
class="btn btn-outline-danger btn-sm ms-2 mt-2"
data-bs-toggle="modal"
data-bs-target="#modal-archive-del-{{ archive.id }}"
><i class="bi bi-trash"></i> DEL</button>
</div>
</div>
</li>

View File

@@ -0,0 +1,49 @@
<div class="d-sm-flex w-100 justify-content-between mb-2">
<h4 class="card-title mb-1">Ticket: {{ ticket.number }}</h4>
<small><i class="bi bi-clock-history"></i> {{ ticket.time_create|date:"D d.m.y H:i" }}</small>
</div>
<div class="form-check form-switch form-check-reverse d-flex w-100 justify-content-left">
<label class="form-check-label" for="ticket-state">Resolved:</label>
<input
class="form-check-input ms-2 mb-2"
type="checkbox"
role="switch"
name="ticket-state"
ticket-state-url="{% url 'collector_api:ticket-detail' ticket.number %}"
{% if ticket.resolved %} ticket-state-switch="1" {% endif %}
{% if ticket.resolved %} checked {% endif %}>
</div>
<div class="col-xl-6 mb-2">
<h6 class="card-title mb-1">Platform: {{ ticket.platform.pretty_name }}</h6>
<h6 class="card-title mb-3">Owner: {{ ticket.user.username }}</h6>
<!-- Token -->
<div class="input-group input-group mb-3">
<span class="input-group-text" id="inputGroup-sizing-sm"><i class="bi bi-key"></i></span>
<!--Token attempts-->
<span class="input-group-text" id="inputGroup-sizing-sm">
<span
class="badge
{% if ticket.attempts <= 0 %}
bg-danger
{% elif ticket.attempts < 5 %}
text-dark bg-warning
{% else %}
bg-primary
{% endif %} rounded-pill">{{ ticket.attempts }}
</span>
</span>
<input
name="ticket-token"
class="form-control"
type="text"
value="{{ ticket.token }}"
aria-label="Disabled input example"
aria-describedby="inputGroup-sizing-sm"
disabled
readonly>
<button
class="input-group-text token-clipboard"
id="inputGroup-sizing-sm"><i class="bi bi-clipboard"></i>
</button>
</div>
</div>

View File

@@ -0,0 +1,60 @@
{% extends 'collector/base.html' %}
{% load static %}
{% load collector_extras %}
{% block title %} {{ title }} {% endblock title %}
{% block main %}
<div class="container mt-3">
<div class="row">
{% csrf_token %}
<div class="card">
<div class="card-body" aria-current="true">
{% include 'collector/includes/ticket_info.html' %}
<div class="col-xl-6 mt-1 mb-2">
{% if ticket.note %}
<div class="card">
<div class="card-header">
Note:
</div>
<div class="card-body">
<div class="card-text">
{{ ticket.note | markdown | safe }}
</div>
</div>
</div>
{% endif %}
</div>
<!-- Logs -->
{% if ticket.archive_set.all %}
<ul class="list-group col-xl-6 mb-2 mt-2">
{% for archive in ticket.archive_set.all %}
{% include 'collector/includes/ticket_archives.html' %}
{% endfor %}
</ul>
{% endif %}
<!-- Card buttons -->
<div class="d-flex w-100 justify-content-between">
<a
href="{% url 'collector:update' ticket.platform.name ticket.number %}"
class="btn btn-outline-warning mb-1 mt-1"
><i class="bi bi-pencil-square"></i> Edit</a>
<button
class="btn btn-outline-danger mb-1 mt-1"
data-bs-toggle="modal"
data-bs-target="#modal-ticket-del-{{ ticket.number }}"
><i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
<!-- Modal Archive -->
{% for archive in ticket.archive_set.all %}
{% include 'collector/includes/modal_archive.html' %}
{% endfor %}
<!-- Modal Ticket -->
{% include 'collector/includes/modal_ticket.html' %}
</div>
{% endblock main %}
{% block jquery %}
<script src="{% static 'collector/js/jq.ticket.detail.js' %}"></script>
{% endblock jquery %}

View File

@@ -0,0 +1,16 @@
{% extends 'collector/base.html' %}
{% load static %}
{% load crispy_forms_tags %}
{% block title %} {{ title }} {% endblock title %}
{% block main %}
<div class="container mt-3">
<div class="card">
<div class="card-header">
<h3>Ticket:</h3>
</div>
<div class="card-body">
{% crispy form %}
</div>
</div>
</div>
{% endblock main %}

View File

@@ -0,0 +1,22 @@
{% extends 'collector/base.html' %}
{% load static %}
{% block title %} {{ title }} {% endblock title %}
{% block content %}
<div class="container mt-3">
<div class="row">
<form method="post" action="{% url 'collector:delete' ticket.number %}">
{% csrf_token %}
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>Cancel</button>
<button
type="submit"
class="btn btn-danger btn-archive-eraser"
data-bs-dismiss="modal"
>Delete</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends 'collector/base.html' %}
{% load static %}
{% load collector_extras %}
{% block title %} {{ title }} {% endblock title %}
{% block main %}
<div class="container mt-3">
{% csrf_token %}
<!-- Ticket -->
{% for ticket in tickets %}
<ul id="div-ticket-{{ ticket.number }}" class="list-group mb-2">
<li class="list-group-item list-group-item-action disable" aria-current="true">
{% include 'collector/includes/ticket_info.html' %}
<div class="col-xl-6 mt-1 mb-2">
<div class="accordion" id="#archive_{{ ticket.number }}">
{% if ticket.note %}
<div class="accordion-item">
<h2 class="accordion-header">
<button
class="accordion-button collapsed"
type="button" data-bs-toggle="collapse"
data-bs-target="#collapse_{{ ticket.number}}_note"
aria-expanded="false"
aria-controls="collapse_{{ ticket.number }}"
><i class="bi bi-journal-text me-2"></i> Note</button>
</h2>
<div id="collapse_{{ ticket.number }}_note"
class="accordion-collapse collapse"
data-bs-parent="#archive_{{ ticket.number }}_note"
>
<div class="accordion-body">
<p class="mb-1">{{ ticket.note |markdown |safe }}</p>
</div>
</div>
</div>
{% endif %}
{% if ticket.archive_set.all %}
<!-- Logs -->
<div class="accordion-item">
<h3 class="accordion-header">
<button
class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapse_{{ ticket.number }}"
aria-expanded="true" aria-controls="collapse_{{ ticket.number }}"
><i class="bi bi-file-zip me-2"></i> Logs</button>
</h3>
<div
id="collapse_{{ ticket.number }}"
class="accordion-collapse collapse"
data-bs-parent="#archive_{{ ticket.number }}"
>
<div class="accordion-body">
<ul class="list-group col mb-2 mt-2">
{% for archive in ticket.archive_set.all %}
{% include 'collector/includes/ticket_archives.html' %}
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<div class="d-flex w-100 justify-content-between">
<a
href="{{ ticket.get_absolute_url }}"
class="btn btn-outline-primary mb-1 mt-1"
><i class="bi bi-arrow-return-right"></i> Open</a>
<button
class="btn btn-outline-danger mb-1 mt-1"
data-bs-toggle="modal"
data-bs-target="#modal-ticket-del-{{ ticket.number }}"
><i class="bi bi-trash"></i> Delete
</button>
</div>
</li>
</ul>
<!-- Modal ticket -->
{% include 'collector/includes/modal_ticket.html' %}
<!-- Modal archive -->
{% for archive in ticket.archive_set.all %}
{% include 'collector/includes/modal_archive.html' %}
{% endfor %}
{% endfor %}
{% include 'collector/includes/pagination.html' %}
</div>
{% endblock main %}
{% block bs %}
<script src="{% static 'collector/js/bs.tooltip.js' %}"></script>
{% endblock bs %}
{% block jquery %}
<script src="{% static 'collector/js/jq.ticket.detail.js' %}"></script>
{% endblock jquery %}

View File

@@ -0,0 +1,57 @@
import markdown as md
from django import template
from django.template.defaultfilters import stringfilter
from apps.collector.models import Platform
register = template.Library()
@register.simple_tag()
def get_platforms():
return Platform.objects.all()
@register.filter(name='sizify')
def sizify(value: int) -> str:
"""Simple kb/mb/gb size snippet for templates:
{{ Archive.file.size|sizify }}
Args:
value (int): size of file from Filefield
Returns:
str: format human readable size like 4.2 Gb
"""
if value < 512000:
value = value / 1024.0
ext = 'Kb'
elif value < 4194304000:
value = value / 1048576.0
ext = 'Mb'
else:
value = value / 1073741824.0
ext = 'Gb'
return f'{round(value, 2)} {ext}'
@register.filter(name='clean_filename')
def clean_filename(filename: str) -> str:
"""delete prefix ticket number folder for template
Args:
filename (str): filename from Filefield
Returns:
str: only filename
"""
return filename.rpartition('/')[-1]
@register.filter(name='markdown')
@stringfilter
def markdown(value):
return md.markdown(value, extensions=['markdown.extensions.fenced_code'])

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,50 @@
from django.urls import path
from . import views
app_name = 'collector'
# █░█░█ █▀▀ █▄▄
# ▀▄▀▄▀ ██▄ █▄█
# -- -- -- -- --
urlpatterns = [
# CREATE:
path(
'tickets/create/',
views.CreateTicket.as_view(),
name='create'
),
# READ:
path(
'',
views.ListAllTickets.as_view(),
name='index'
),
path(
'tickets/',
views.ListAllTickets.as_view(),
name='tickets'
),
path(
'tickets/show/<slug:platform>/',
views.ListPlatformTickets.as_view(),
name='platform'
),
path(
'tickets/show/<slug:platform>/<int:ticket>/',
views.DetailTicket.as_view(),
name='ticket'
),
path(
'archives/<path:path>',
views.ArchiveHandlerView.as_view(),
name="download"
),
# UPDATE:
path(
'tickets/update/<slug:platform>/<int:ticket>/',
views.UpdateTicket.as_view(),
name='update'
),
]

View File

@@ -0,0 +1,43 @@
import os
def logs_dir_path(instance, filename):
"""
file will be uploaded to
MEDIA_ROOT_FOR_SENSITIVE_FILES/<ticket-token>/<filename>
"""
return f'{instance.ticket.number}/{filename}'
# deprecated
def get_file_size(file_path, unit='bytes'):
file_size = os.path.getsize(file_path)
exponents_map = {'bytes': 0, 'kb': 1, 'mb': 2, 'gb': 3}
if unit not in exponents_map:
raise ValueError("Must select from \
['bytes', 'kb', 'mb', 'gb']")
else:
size = file_size / 1024 ** exponents_map[unit]
return round(size, 3)
# deprecated
def is_ajax(request):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return True
class PageTitleViewMixin:
title = 'Collector'
def get_title(self, *args, **kwargs):
"""
Return the class title attr by default,
but you can override this method to further customize
"""
return self.title
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = self.get_title()
return context

View File

@@ -0,0 +1,109 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import FileResponse
from django.views import generic
from django.views.generic.detail import SingleObjectMixin
from django.db.models import Q
from two_factor.views import OTPRequiredMixin
from .forms import TicketForm
from .models import Archive, Ticket
from .utils import PageTitleViewMixin
class ArchiveHandlerView(
OTPRequiredMixin,
LoginRequiredMixin,
SingleObjectMixin,
generic.View):
model = Archive
slug_field = 'file'
slug_url_kwarg = 'path'
def get(self, request, path):
self.object = self.get_object()
return FileResponse(self.object.file)
class CreateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.CreateView):
model = Ticket
form_class = TicketForm
template_name = 'collector/ticket_create.html'
def get_title(self):
return f'{self.title} - create'
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
class UpdateTicket(LoginRequiredMixin, PageTitleViewMixin, generic.UpdateView):
model = Ticket
form_class = TicketForm
template_name = 'collector/ticket_create.html'
slug_field = 'number'
slug_url_kwarg = 'ticket'
def get_title(self, **kwargs):
return f'{self.title} - {self.kwargs.get("ticket", "update")}'
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
class ListAllTickets(LoginRequiredMixin, PageTitleViewMixin, generic.ListView):
model = Ticket
template_name = 'collector/tickets.html'
context_object_name = 'tickets'
paginate_by = 5
title = 'Collector - tickets'
def get_queryset(self):
search_query = self.request.GET.get('search', '')
if search_query:
query_list = []
try:
for item in search_query.split(','):
query_list.append(int(item))
except ValueError:
return super().get_queryset()
queryset = self.model.objects.filter(
Q(number__in=query_list) | Q(number__icontains=query_list[0])
)
self.paginate_by = 100 # ? fake disable pagination)
return queryset
return super().get_queryset()
class ListPlatformTickets(
LoginRequiredMixin,
PageTitleViewMixin,
generic.ListView
):
model = Ticket
template_name = 'collector/tickets.html'
context_object_name = 'tickets'
# allow_empty = False
paginate_by = 5
def get_title(self, **kwargs):
return f'{self.title} - {self.kwargs.get("platform", "tickets")}'
def get_queryset(self):
return Ticket.objects.filter(
platform__name=self.kwargs.get('platform')
)
class DetailTicket(LoginRequiredMixin, PageTitleViewMixin, generic.DetailView):
model = Ticket
template_name = 'collector/ticket.html'
context_object_name = 'ticket'
slug_field = 'number'
slug_url_kwarg = 'ticket'
def get_title(self, **kwargs):
return f'{self.title} - {self.kwargs.get("ticket", "show")}'