A mo-mo-mo-monster

This commit is contained in:
Kumi 2024-03-11 15:56:03 +01:00
commit d6e76e8cfd
Signed by: kumi
GPG key ID: ECBCC9082395383F
56 changed files with 8523 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
settings.ini
*.pyc
__pycache__/
db.sqlite3
node_modules/
static/js/*bundle.js*
media/
.venv/
.vscode/

12
README.md Normal file
View file

@ -0,0 +1,12 @@
# Quackscape - A panoramic content management system for the web.
Quackscape is a content management system for panoramic/VR photos and videos. It is designed to be a simple and easy to use platform for sharing panoramic content on the web. It is built using the Django web framework and is designed to be easily deployable on a variety of platforms.
## Requirements
- Python 3.8+
- Redis
- ffmpeg
- NodeJS / NPM
- MariaDB or MySQL (optional but recommended)
- A web server (Caddy, gunicorn, Nginx, Apache, etc.) (optional but recommended)

40
assets/js/api.js Normal file
View file

@ -0,0 +1,40 @@
import SwaggerClient from "swagger-client";
const url = String(new URL("/api/", document.baseURI));
const api = new SwaggerClient(url);
api.then(
(client) => (window.client = client),
(reason) => console.error("Failed to load OpenAPI spec: " + reason)
);
function getScene(uuid) {
return api
.then(
(client) => client.apis.tours.tours_api_scenes_retrieve({ id: uuid }),
(reason) => console.error("Failed to load OpenAPI spec: " + reason)
)
.then(
(result) => result,
(reason) => console.error("Failed to execute API call: " + reason)
);
}
function getSceneElement(scene_uuid, uuid) {
return api
.then(
(client) =>
client.apis.tours.tours_api_scene_elements_retrieve({
scene: scene_uuid,
id: uuid,
}),
(reason) => console.error("Failed to load OpenAPI spec: " + reason)
)
.then(
(result) => result,
(reason) => console.error("Failed to execute API call: " + reason)
);
}
export { getScene, getSceneElement };

173
assets/js/editor.js Normal file
View file

@ -0,0 +1,173 @@
import { getScene, getSceneElement } from "./api";
import { Modal } from "bootstrap";
let clickTimestamp = 0;
var editModal = null;
// Find parent quackscape-scene for ID
function findParentScene(element) {
var parent = element.parentElement;
while (parent.tagName != "QUACKSCAPE-SCENE") {
parent = parent.parentElement;
}
return parent;
}
document.addEventListener("DOMContentLoaded", function () {
editModal = new Modal("#editModal");
});
// Distinguishing clicks from drags based on duration.
// TODO: Find a better way to distinguish these.
function addEventListeners(element) {
element.addEventListener("mousedown", function (event) {
clickTimestamp = event.timeStamp;
});
element.addEventListener("mouseup", function (event) {
if (event.timeStamp - clickTimestamp > 200) {
// Ignoring this, we only handle regular short clicks.
// TODO: Find a way to handle drags of elements
return;
} else {
handleClick(event);
}
// Right-clicks are definitely intentional.
element.addEventListener("contextmenu", function (event) {
handleClick(event);
});
});
}
// Open a modal for creating a new Element
function startCreateModal(event) {
var modalLabel = document.getElementById("editModalLabel");
modalLabel.textContent = "Create Element";
var modalContent = document.getElementById("editModalContent");
var thetaStart = cartesianToTheta(
event.detail.intersection.point.x,
event.detail.intersection.point.z
);
modalContent.innerHTML = `<b>Creating element at:</b><br/>
X: ${event.detail.intersection.point.x}<br/>
Y: ${event.detail.intersection.point.y}<br/>
Z: ${event.detail.intersection.point.z}<br/>
Calculated Theta: ${thetaStart}<br/>
Parent Element Type: ${event.srcElement.tagName}<br/>
<hr/>
<form id="newElementForm">
<select class="form-select" aria-label="Default select example">
<option selected>Select Element Type</option>
<option value="1">Marker</option>
<option value="2">Image</option>
<option value="3">Teleport</option>
</select>
</form>
`;
editModal.show();
}
function startModifyModal(event) {
var modalLabel = document.getElementById("editModalLabel");
modalLabel.textContent = "Modify Element";
// Get element from API
var scene = findParentScene(event.target);
var element_data_request = getSceneElement(
scene.getAttribute("id"),
event.target.getAttribute("id")
);
element_data_request.then((element_data) => {
console.log(element_data);
var modalContent = document.getElementById("editModalContent");
modalContent.innerHTML = `<b>Modifying element:</b><br/>
Element Type: ${event.srcElement.tagName}<br/>
Element ID: ${event.target.getAttribute("id")}<br/>
Element data: ${JSON.stringify(element_data.obj)}<br/>
<hr/>
<form id="modifyElementForm">
</form>
`;
editModal.show();
});
}
function cartesianToTheta(x, z) {
// Calculate the angle in radians
let angleRadians = Math.atan2(z, x);
// Convert to degrees
let angleDegrees = angleRadians * (180 / Math.PI);
// A-Frame's thetaStart is measured from the positive X-axis ("right" direction)
// and goes counter-clockwise, so this should directly give us the thetaStart value.
let thetaStart = 90 - angleDegrees;
// Since atan2 returns values from -180 to 180, let's normalize this to 0 - 360
thetaStart = thetaStart < 0 ? thetaStart + 360 : thetaStart;
return thetaStart;
}
function latLonToXYZ(lat, lon, radius = 5) {
// Convert lat/lon to X/Y/Z coordinates on the sphere
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lon + 180) * (Math.PI / 180);
const x = -(radius * Math.sin(phi) * Math.cos(theta));
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
return { x, y, z };
}
function handleClick(event) {
console.log(event);
if (event.target.tagName == "A-SKY") {
startCreateModal(event);
} else {
startModifyModal(event);
}
}
document.addEventListener("loadedQuackscapeScene", function (event) {
// Get the scene
var scene = document.querySelector("a-scene");
// Get all children
var children = scene.children;
for (var i = 0; i < children.length; i++) {
var child = children[i];
if (child.tagName.startsWith("A-")) {
// Remove original onclick events
if (child.hasAttribute("onclick")) {
child.removeAttribute("onclick");
}
// Add new event listeners
addEventListeners(child);
// Add click-drag component to all a-entity elements
child.setAttribute("click-drag", "");
}
}
});

1
assets/js/frontend.js Normal file
View file

@ -0,0 +1 @@
import '../scss/frontend.scss';

124
assets/js/scene.js Normal file
View file

@ -0,0 +1,124 @@
import { getScene } from "./api";
require("aframe");
// Define the <quackscape-scene> element
class QuackscapeScene extends HTMLElement {
connectedCallback() {
// When one is created, automatically load the scene
this.scene = this.getAttribute("scene");
this.x = this.getAttribute("x");
this.y = this.getAttribute("y");
loadScene(this.scene, this.x, this.y, this);
}
}
document.addEventListener("contextmenu", function(event) {
event.preventDefault();
})
customElements.define("quackscape-scene", QuackscapeScene);
// Function to load a scene into a destination object
// x and y signify the initial looking direction, -1 for the scene's default
async function loadScene(scene_id, x = -1, y = -1, destination = null) {
// Get WebGL maximum texture size
var canvas = document.createElement("canvas");
var gl =
canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
if (!gl) {
alert("Unable to initialize WebGL. Your browser may not support it.");
return;
}
var maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
// Get scene information from API
getScene(scene_id).then((response) => {
var scene = response.obj;
// Get largest image that will fit in the texture size from scene.resolutions
// First, order the resolutions by width
var resolutions = scene.base_content.resolutions.sort(
(a, b) => b.width - a.width
);
// Then, find the first resolution that is less than or equal to the max texture size
var content = resolutions.find(
(resolution) => resolution.width <= maxTextureSize
);
// Select a destination element if not specified
if (!destination) {
destination = document.getElementsByTagName("quackscape-scene")[0];
}
destination.setAttribute("id", scene_id);
// Start by removing any existing scenes
var scenes = document.getElementsByTagName("a-scene");
if (scenes.length > 0) {
scenes[0].remove();
}
// Now, build the scene element
var a_scene = document.createElement("a-scene");
a_scene.setAttribute("cursor", "rayOrigin: mouse");
// Create a-camera element
var rig = document.createElement("a-entity");
rig.setAttribute("id", "rig");
// Rotate camera if requested
if (x != -1 && y != -1) {
rig.setAttribute("rotation", x + " " + y + " " + "0");
}
var camera = document.createElement("a-camera");
camera.setAttribute("wasd-controls-enabled", "false"); // TODO: Find a more elegant way to disable z axis movement
rig.appendChild(camera);
a_scene.appendChild(rig);
// Create a-assets element
var assets = document.createElement("a-assets");
a_scene.appendChild(assets);
// Add background image as sky
var sky = document.createElement("a-sky");
sky.setAttribute("src", "#" + content.id);
a_scene.appendChild(sky);
// Add img element to assets
var background = document.createElement("img");
background.setAttribute("id", content.id);
background.setAttribute("src", content.file);
assets.appendChild(background);
// Add elements to scene
scene.elements.forEach((element) => {
var node = document.createElement(element.data.tag);
node.setAttribute("id", element.data.id);
for (const [key, value] of Object.entries(element.data.attributes)) {
node.setAttribute(key, value);
}
// Add node to scene
a_scene.appendChild(node);
});
destination.appendChild(a_scene);
// Dispatch a signal for the editor to pick up
const loaded_event = new CustomEvent('loadedQuackscapeScene');
document.dispatchEvent(loaded_event);
});
}
window.loadScene = loadScene;
export { loadScene };

View file

@ -0,0 +1,2 @@
@import "bootstrap/scss/bootstrap";

22
manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'quackscape.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
(execute_from_command_line(sys.argv))
if __name__ == '__main__':
main()

6458
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "quackscape",
"version": "0.0.1",
"description": "Quackscape is a content management system for panoramic/VR photos and videos. It is designed to be a simple and easy to use platform for sharing panoramic content on the web. It is built using the Django web framework and is designed to be easily deployable on a variety of platforms.",
"scripts": {
"build": "webpack --mode production",
"start": "python manage.py runserver",
"build:dev": "webpack --mode development",
"watch": "webpack --mode production --watch",
"watch:dev": "webpack --mode development --watch"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"autoprefixer": "^10.4.18",
"babel-loader": "^9.1.3",
"css-loader": "^6.10.0",
"mini-css-extract-plugin": "^2.8.1",
"postcss-loader": "^8.1.1",
"sass": "^1.71.1",
"sass-loader": "^14.1.1",
"style-loader": "^3.3.4",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"aframe": "^1.5.0",
"bootstrap": "^5.3.3",
"swagger-client": "^3.26.0"
}
}

3
quackscape/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ['celery_app']

16
quackscape/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for quackscape project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'quackscape.settings')
application = get_asgi_application()

9
quackscape/celery.py Normal file
View file

@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'quackscape.settings')
app = Celery('quackscape')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

View file

@ -0,0 +1,15 @@
from django.core.management.base import BaseCommand
import subprocess
class Command(BaseCommand):
help = 'Installs npm packages and builds assets using Webpack.'
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Installing npm packages...'))
subprocess.run(['npm', 'install', "--include=dev"], check=True)
self.stdout.write(self.style.SUCCESS('Building assets with Webpack...'))
subprocess.run(['npm', 'run', 'build'], check=True)
self.stdout.write(self.style.SUCCESS('Assets have been built successfully.'))

View file

@ -0,0 +1,31 @@
import asyncio
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = (
"Starts both the Django development server and a Celery worker asynchronously"
)
async def start_django(self):
# Command to run the Django development server
proc = await asyncio.create_subprocess_shell("python manage.py runserver")
stdout, stderr = await proc.communicate()
async def start_celery(self):
# Command to run the Celery worker
proc = await asyncio.create_subprocess_shell("python manage.py runworker")
stdout, stderr = await proc.communicate()
async def run_both(self):
django_task = asyncio.create_task(self.start_django())
celery_task = asyncio.create_task(self.start_celery())
await asyncio.gather(django_task, celery_task)
def handle(self, *args, **options):
try:
asyncio.run(self.run_both())
except KeyboardInterrupt:
print("Shutting down...")

View file

@ -0,0 +1,14 @@
from django.core.management.base import BaseCommand
import subprocess
class Command(BaseCommand):
help = "Starts the Celery worker"
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS("Starting Celery worker..."))
try:
subprocess.call(["celery", "-A", "quackscape", "worker", "--loglevel=info"])
except KeyboardInterrupt:
self.stdout.write(self.style.SUCCESS("Stopping Celery worker..."))

238
quackscape/settings.py Normal file
View file

@ -0,0 +1,238 @@
from autosecretkey import AutoSecretKey
from pathlib import Path
from urllib.parse import urlparse
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
ASK = AutoSecretKey("settings.ini")
SECRET_KEY = ASK.secret_key
DEBUG = ASK.config.getboolean(
"Quackscape", "Debug", fallback=False
)
ALLOWED_HOSTS = [
host.strip()
for host in ASK.config.get("Quackscape", "Host", fallback="*").split(",")
]
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS]
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"polymorphic",
"rest_framework",
"django_celery_beat",
"django_celery_results",
'drf_spectacular',
'drf_spectacular_sidecar',
"quackscape",
"quackscape.users",
"quackscape.tours",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "quackscape.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "quackscape.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
if (dbtype := "MySQL") in ASK.config or (dbtype := "MariaDB") in ASK.config:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": ASK.config.get(dbtype, "Database"),
"USER": ASK.config.get(dbtype, "Username"),
"PASSWORD": ASK.config.get(dbtype, "Password"),
"HOST": ASK.config.get(dbtype, "Host", fallback="localhost"),
"PORT": ASK.config.getint(dbtype, "Port", fallback=3306),
}
}
elif (dbtype := "Postgres") in ASK.config or (dbtype:= "PostgreSQL") in ASK.config:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": ASK.config.get(dbtype, "Database"),
"USER": ASK.config.get(dbtype, "Username"),
"PASSWORD": ASK.config.get(dbtype, "Password"),
"HOST": ASK.config.get(dbtype, "Host", fallback="localhost"),
"PORT": ASK.config.getint(dbtype, "Port", fallback=5432),
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_USER_MODEL = "users.QuackUser"
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
"django.contrib.auth.hashers.ScryptPasswordHasher",
]
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = "static/"
STATICFILES_DIRS = [
BASE_DIR / "static",
]
# Settings for uploaded files
MEDIA_URL = "/media/"
MEDIA_ROOT = ASK.config.get("Quackscape", "MediaRoot", fallback=BASE_DIR / "media")
if "S3" in ASK.config:
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
if not ASK.config.getboolean("S3", "LocalStatic", fallback=False):
STATICFILES_STORAGE = "storages.backends.s3boto3.S3StaticStorage"
AWS_ACCESS_KEY_ID = ASK.config.get("S3", "AccessKey")
AWS_SECRET_ACCESS_KEY = ASK.config.get("S3", "SecretKey")
AWS_STORAGE_BUCKET_NAME = ASK.config.get("S3", "Bucket")
AWS_S3_ENDPOINT_URL = ASK.config.get("S3", "Endpoint")
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Django Rest Framework settings
SPECTACULAR_SETTINGS = {
'SWAGGER_UI_DIST': 'SIDECAR',
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
'REDOC_DIST': 'SIDECAR',
}
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
# Celery settings
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'django-db'
# Quackscape settings
QUACKSCAPE_CONTENT_RESOLUTIONS = [
# A list of allowed resolutions for content. Width must be twice the height.
(1024, 512),
(2048, 1024),
(4096, 2048),
(8192, 4096),
(16384, 8192),
(32768, 16384),
(65536, 32768)
]
# ffmpeg settings
FFMPEG_OPTIONS = {
'default': {
'global_options': '-hide_banner -y',
'input_options': '',
'output_options': '-c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k',
},
'high_quality': {
'global_options': '-hide_banner -y',
'input_options': '',
'output_options': '-c:v libx264 -preset slow -crf 18 -c:a aac -b:a 320k',
},
'nvidia': {
'global_options': '-hide_banner -y',
'input_options': '',
'output_options': '-c:v h264_nvenc -preset slow -cq:v 18 -c:a aac -b:a 320k',
},
}
FFMPEG_DEFAULT_OPTION = ASK.config.get("ffmpeg", "DefaultOption", fallback="default")
# TODO: CSP settings

View file

12
quackscape/tours/admin.py Normal file
View file

@ -0,0 +1,12 @@
from django.contrib import admin
from .models import *
admin.site.register(Scene)
admin.site.register(TextElement)
admin.site.register(ImageElement)
admin.site.register(TeleportElement)
admin.site.register(OriginalImage)
admin.site.register(OriginalVideo)
admin.site.register(MediaResolution)
admin.site.register(Category)

6
quackscape/tours/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ToursConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'quackscape.tours'

View file

@ -0,0 +1,14 @@
from django.core.management.base import BaseCommand
from quackscape.tours.tasks import create_image_resolutions, create_video_resolutions
from quackscape.tours.models import OriginalImage, OriginalVideo
class Command(BaseCommand):
help = 'Creates lower-resolution versions of original content as required.'
def handle(self, *args, **options):
for original_image in OriginalImage.objects.all():
create_image_resolutions(original_image)
for original_video in OriginalVideo.objects.all():
create_video_resolutions(original_video)

View file

@ -0,0 +1,101 @@
# Generated by Django 5.0.3 on 2024-03-07 19:49
import django.db.models.deletion
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Element',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('x', models.FloatField(default=0.0)),
('y', models.FloatField(default=0.0)),
('z', models.FloatField(default=0.0)),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='OriginalMedia',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('file', models.FileField(upload_to='originals/')),
('media_type', models.CharField(choices=[('image', 'Image'), ('video', 'Video')], max_length=5)),
],
),
migrations.CreateModel(
name='ImageElement',
fields=[
('element_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tours.element')),
('image', models.ImageField(upload_to='elements/images/')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('tours.element',),
),
migrations.CreateModel(
name='MarkerElement',
fields=[
('element_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tours.element')),
('label', models.CharField(max_length=100)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('tours.element',),
),
migrations.CreateModel(
name='VideoElement',
fields=[
('element_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tours.element')),
('video', models.FileField(upload_to='elements/videos/')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('tours.element',),
),
migrations.CreateModel(
name='MediaResolution',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('width', models.IntegerField()),
('height', models.IntegerField()),
('file', models.FileField(upload_to='resolutions/')),
('original_media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resolutions', to='tours.originalmedia')),
],
),
migrations.CreateModel(
name='Scene',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('base_content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scenes', to='tours.originalmedia')),
],
),
migrations.AddField(
model_name='element',
name='scene',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='elements', to='tours.scene'),
),
]

View file

@ -0,0 +1,125 @@
# Generated by Django 5.0.3 on 2024-03-08 14:25
import django.db.models.deletion
import quackscape.tours.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('tours', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='OriginalImage',
fields=[
('originalmedia_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tours.originalmedia')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('tours.originalmedia',),
),
migrations.CreateModel(
name='OriginalVideo',
fields=[
('originalmedia_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tours.originalmedia')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('tours.originalmedia',),
),
migrations.AlterModelOptions(
name='originalmedia',
options={'base_manager_name': 'objects'},
),
migrations.RemoveField(
model_name='originalmedia',
name='media_type',
),
migrations.AddField(
model_name='imageelement',
name='height',
field=models.IntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='imageelement',
name='width',
field=models.IntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='originalmedia',
name='height',
field=models.IntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='originalmedia',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='originalmedia',
name='width',
field=models.IntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='videoelement',
name='height',
field=models.IntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='videoelement',
name='width',
field=models.IntegerField(default=0),
preserve_default=False,
),
migrations.AlterField(
model_name='imageelement',
name='image',
field=models.ImageField(upload_to=quackscape.tours.models.upload_to),
),
migrations.AlterField(
model_name='mediaresolution',
name='id',
field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='originalmedia',
name='id',
field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='scene',
name='id',
field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='videoelement',
name='video',
field=models.FileField(upload_to=quackscape.tours.models.upload_to),
),
migrations.CreateModel(
name='TeleportElement',
fields=[
('imageelement_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tours.imageelement')),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teleports_in', to='tours.scene')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('tours.imageelement',),
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 5.0.3 on 2024-03-08 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0002_originalimage_originalvideo_and_more'),
]
operations = [
migrations.AddField(
model_name='teleportelement',
name='destination_x',
field=models.FloatField(default=-1.0),
),
migrations.AddField(
model_name='teleportelement',
name='destination_y',
field=models.FloatField(default=-1.0),
),
migrations.AddField(
model_name='teleportelement',
name='label',
field=models.CharField(default='', max_length=100),
preserve_default=False,
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 5.0.3 on 2024-03-08 15:06
import quackscape.tours.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0003_teleportelement_destination_x_and_more'),
]
operations = [
migrations.AlterField(
model_name='originalmedia',
name='file',
field=models.FileField(upload_to=quackscape.tours.models.upload_to),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.0.3 on 2024-03-08 15:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0004_alter_originalmedia_file'),
]
operations = [
migrations.AlterField(
model_name='originalmedia',
name='height',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='originalmedia',
name='width',
field=models.IntegerField(blank=True, null=True),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 5.0.3 on 2024-03-09 09:26
import quackscape.tours.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0005_alter_originalmedia_height_alter_originalmedia_width'),
]
operations = [
migrations.AddField(
model_name='scene',
name='default_x',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='scene',
name='default_y',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='element',
name='z',
field=models.FloatField(default=180.0),
),
migrations.AlterField(
model_name='mediaresolution',
name='file',
field=models.FileField(upload_to=quackscape.tours.models.upload_to),
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 5.0.3 on 2024-03-09 10:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0006_scene_default_x_scene_default_y_alter_element_z_and_more'),
]
operations = [
migrations.RemoveField(
model_name='markerelement',
name='label',
),
migrations.RemoveField(
model_name='teleportelement',
name='label',
),
migrations.AddField(
model_name='element',
name='label',
field=models.CharField(default='', max_length=100),
preserve_default=False,
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 5.0.3 on 2024-03-10 13:01
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0007_remove_markerelement_label_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Category',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('title', models.CharField(max_length=100)),
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_categories', to=settings.AUTH_USER_MODEL)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subcategories', to='tours.category')),
],
),
migrations.CreateModel(
name='CategoryPermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tours.category')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.0.3 on 2024-03-10 13:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0008_category_categorypermission'),
]
operations = [
migrations.AddField(
model_name='originalmedia',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='tours.category'),
),
migrations.AddField(
model_name='scene',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='tours.category'),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 5.0.3 on 2024-03-11 08:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('tours', '0009_originalmedia_category_scene_category'),
]
operations = [
migrations.RenameModel(
old_name='MarkerElement',
new_name='TextElement',
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.0.3 on 2024-03-11 08:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0010_rename_markerelement_textelement'),
]
operations = [
migrations.RemoveField(
model_name='textelement',
name='element_ptr',
),
migrations.AddField(
model_name='textelement',
name='imageelement_ptr',
field=models.OneToOneField(auto_created=True, default=0, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tours.imageelement'),
preserve_default=False,
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 5.0.3 on 2024-03-11 09:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tours', '0011_remove_textelement_element_ptr_and_more'),
]
operations = [
migrations.AddField(
model_name='scene',
name='public',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='textelement',
name='font',
field=models.CharField(default='Arial', max_length=100),
),
migrations.AddField(
model_name='textelement',
name='font_size',
field=models.IntegerField(default=20),
),
migrations.AddField(
model_name='textelement',
name='text',
field=models.TextField(default=''),
preserve_default=False,
),
]

View file

255
quackscape/tours/models.py Normal file
View file

@ -0,0 +1,255 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.urls import reverse_lazy
from polymorphic.models import PolymorphicModel
from PIL import Image
from typing import Tuple
from pathlib import Path
import uuid
import math
from .tasks import *
def upload_to(instance, filename):
return f"uploads/{instance.id}/{Path(filename).name}"
class Category(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
title = models.CharField(max_length=100)
owner = models.ForeignKey(
get_user_model(),
related_name="owned_categories",
on_delete=models.SET_NULL,
null=True,
)
parent = models.ForeignKey(
"Category",
related_name="subcategories",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
def user_has_permission(self, user):
return (
user.is_superuser
or user.is_staff
or user == self.owner
or CategoryPermission.objects.filter(category=self, user=user).exists()
or (self.parent.user_has_permission(user) if self.parent else False)
)
def __str__(self):
return f"{self.title} ({self.owner})"
class CategoryPermission(models.Model):
category = models.ForeignKey(Category, on_delete=models.CASCADE)
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
class Element(PolymorphicModel):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
scene = models.ForeignKey(
"Scene", related_name="elements", on_delete=models.CASCADE
)
x = models.FloatField(default=0.0)
y = models.FloatField(default=0.0)
z = models.FloatField(default=180.0)
label = models.CharField(max_length=100)
def data(self) -> dict:
raise NotImplementedError("Subclasses must implement this method")
def __str__(self):
return self.label
class ImageElement(Element):
image = models.ImageField(upload_to=upload_to)
width = models.IntegerField()
height = models.IntegerField()
def calculate_theta_values(self) -> Tuple[float, float]:
aspect_ratio = self.width / self.height
circumference = 2 * math.pi * self.z
thetaLength = (360 / circumference) * (aspect_ratio * self.height)
thetaStart = self.x - (thetaLength / 2)
thetaStart = thetaStart % 360
return thetaStart, thetaLength
@property
def thetaStart(self) -> float:
return self.calculate_theta_values()[0]
@property
def thetaLength(self) -> float:
return self.calculate_theta_values()[1]
def data(self) -> dict:
thetaStart, thetaLength = self.calculate_theta_values()
return {
"id": self.id,
"tag": "a-curvedimage",
"attributes": {
"alt": self.label,
"src": self.image.url,
"height": self.height,
"radius": self.z,
"theta-start": thetaStart,
"theta-length": thetaLength,
},
}
class TextElement(ImageElement):
text = models.TextField()
font = models.CharField(max_length=100, default="Arial")
font_size = models.IntegerField(default=20)
def save(self, *args, **kwargs):
self.generate_image_from_text()
super().save(*args, **kwargs)
def generate_image_from_text(self):
# Create an image with PIL
image = Image.new("RGB", (1, 1), color=(255, 255, 255))
draw = ImageDraw.Draw(image)
font = ImageFont.truetype(self.font, self.font_size) # Adjust as needed
text_width, text_height = draw.textsize(self.text, font=font)
image = Image.new("RGBA", (image_width, image_height), color=(255, 255, 255, 0))
draw = ImageDraw.Draw(image)
draw.text((0, 0), self.text, fill=(0, 0, 0), font=font)
# Save the image to a BytesIO object
image_io = io.BytesIO()
image.save(image_io, format="JPEG")
image_io.seek(0)
# Create a Django ContentFile from the BytesIO object
image_file = ContentFile(image_io.read(), name="temp.jpg")
# Assign it to the image field
self.image.save(f"{self.text[:10]}.jpg", image_file, save=False)
# Make sure to close the BytesIO object
image_io.close()
# TODO: Test/Finalize this
class VideoElement(Element):
video = models.FileField(upload_to=upload_to)
width = models.IntegerField()
height = models.IntegerField()
def data(self) -> dict:
return {
"id": self.id,
"tag": "a-video",
"attributes": {
"alt": self.label,
"src": self.video.url,
"height": self.height,
"width": self.width,
"radius": self.z,
"position": f"{self.x} {self.y} {self.z}",
},
}
class TeleportElement(ImageElement):
destination = models.ForeignKey(
"Scene", related_name="teleports_in", on_delete=models.CASCADE
)
destination_x = models.FloatField(default=-1.0)
destination_y = models.FloatField(default=-1.0)
def __str__(self):
return f"{self.scene.title}: {self.label} -> {self.destination.title}"
def data(self) -> dict:
data = super().data()
data["attributes"][
"onclick"
] = f'window.loadScene("{self.destination.id}", {self.destination_x}, {self.destination_y})'
return data
class OriginalMedia(PolymorphicModel):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
file = models.FileField(upload_to=upload_to)
width = models.IntegerField(blank=True, null=True)
height = models.IntegerField(blank=True, null=True)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
def __str__(self):
return self.title
@property
def media_type(self) -> str:
raise NotImplementedError("Subclasses must implement this method")
class OriginalImage(OriginalMedia):
def media_type(self) -> str:
return "image"
def save(self):
if not self.width:
with Image.open(self.file) as img:
self.width, self.height = img.size
super().save()
create_image_resolutions.delay(self.id)
class OriginalVideo(OriginalMedia):
def media_type(self) -> str:
return "video"
class MediaResolution(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
original_media = models.ForeignKey(
OriginalMedia, related_name="resolutions", on_delete=models.CASCADE
)
width = models.IntegerField()
height = models.IntegerField()
file = models.FileField(upload_to=upload_to)
def __str__(self):
return f"{self.original_media.title} - {self.width}x{self.height}"
class Scene(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
base_content = models.ForeignKey(
OriginalMedia, related_name="scenes", on_delete=models.CASCADE
)
default_x = models.IntegerField(default=0)
default_y = models.IntegerField(default=0)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
public = models.BooleanField(default=True)
def user_has_permission(self, user):
return (
user.is_superuser
or user.is_staff
or (self.category and self.category.user_has_permission(user))
)
def __str__(self):
return self.title

View file

@ -0,0 +1,84 @@
from rest_framework import serializers
from rest_polymorphic.serializers import PolymorphicSerializer
from .models import (
Scene,
OriginalMedia,
MediaResolution,
Element,
TeleportElement,
TextElement,
ImageElement,
)
class ElementSerializer(serializers.ModelSerializer):
class Meta:
model = Element
fields = ["id", "data"]
class TeleportElementSerializer(serializers.ModelSerializer):
class Meta:
model = TeleportElement
fields = [
"id",
"label",
"scene",
"destination",
"destination_x",
"destination_y",
"thetaStart",
"thetaLength"
]
class TextElementSerializer(serializers.ModelSerializer):
class Meta:
model = TextElement
fields = ["id", "label", "text"]
class ImageElementSerializer(serializers.ModelSerializer):
class Meta:
model = ImageElement
fields = [
"id",
"label",
"image",
"width",
"height",
"thetaStart",
"thetaLength",
]
class ElementPolymorphicSerializer(PolymorphicSerializer):
model_serializer_mapping = {
TeleportElement: TeleportElementSerializer,
TextElement: TextElementSerializer,
ImageElement: ImageElementSerializer,
}
class MediaResolutionSerializer(serializers.ModelSerializer):
class Meta:
model = MediaResolution
fields = ["id", "width", "height", "file"]
class OriginalMediaSerializer(serializers.ModelSerializer):
resolutions = MediaResolutionSerializer(many=True, read_only=True)
class Meta:
model = OriginalMedia
fields = ["id", "title", "media_type", "width", "height", "resolutions"]
class SceneSerializer(serializers.ModelSerializer):
base_content = OriginalMediaSerializer()
elements = ElementSerializer(many=True, read_only=True)
class Meta:
model = Scene
fields = ["id", "title", "description", "base_content", "elements"]

84
quackscape/tours/tasks.py Normal file
View file

@ -0,0 +1,84 @@
from django.conf import settings
from django.core.files.base import ContentFile
from django.apps import apps
from celery import shared_task
from PIL import Image
from io import BytesIO
import subprocess
import tempfile
import pathlib
import uuid
@shared_task
def create_image_resolutions(image: "OriginalImage"):
OriginalImage = apps.get_model("tours", "OriginalImage")
if isinstance(image, (str, uuid.UUID)):
image = OriginalImage.objects.get(id=image)
with Image.open(image.file) as img:
resolutions = settings.QUACKSCAPE_CONTENT_RESOLUTIONS
for width, height in resolutions:
if width > image.width:
continue
if not image.resolutions.filter(width=width, height=height).exists():
img_resized = img.resize((width, height))
img_resized = img_resized.convert("RGB")
new_image_path = f"{width}x{height}_{image.file.name}"
bio = BytesIO()
img_resized.save(bio, format="JPEG")
resolution = image.resolutions.create(
file=ContentFile(BytesIO.getvalue(bio), name=new_image_path),
width=width,
height=height,
)
@shared_task
def create_video_resolutions(video: "OriginalVideo"):
OriginalVideo = apps.get_model("tours", "OriginalVideo")
if isinstance(video, (str, uuid.UUID)):
video = OriginalVideo.objects.get(id=video)
resolutions = settings.QUACKSCAPE_CONTENT_RESOLUTIONS
ffmpeg_options = settings.FFMPEG_OPTIONS[settings.FFMPEG_DEFAULT_OPTION]
for width, height in resolutions:
if width > video.width:
continue
if not video.resolutions.filter(width=width, height=height).exists():
with tempfile.TemporaryDirectory() as tmpdir:
input_path = pathlib.Path(tmpdir) / video.file.name
with open(input_path, "wb") as f:
f.write(video.file.read())
output_path = (
pathlib.Path(tmpdir) / f"{width}x{height}_{video.file.name}"
)
cmd = f"ffmpeg {ffmpeg_options['global_options']} {ffmpeg_options['input_options']} -i {input_path} -s {width}x{height} {ffmpeg_options['output_options']} {output_path}".split()
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"Error processing video {video_path} to {resolution}: {e}")
with open(output_path, "rb") as f:
resolution = video.resolutions.create(
file=ContentFile(f.read(), name=output_path.name),
width=width,
height=height,
)
output_path.unlink()

View file

@ -0,0 +1,12 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{{ scene.title }} Quackscape</title>
<script type="text/javascript" type="module" src="{% static 'js/api.bundle.js' %}"></script>
<script type="text/javascript" type="module" src="{% static 'js/scene.bundle.js' %}"></script>
</head>
<body>
<quackscape-scene scene="{{ scene.id }}" x="{{ scene.default_x }}" y="{{ scene.default_y }}"></quackscape-scene>
</body>
</html>

View file

@ -0,0 +1,74 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{{ scene.title }} Quackscape</title>
<script
type="text/javascript"
type="module"
src="{% static 'js/frontend.bundle.js' %}"
></script>
<script
type="text/javascript"
type="module"
src="{% static 'js/api.bundle.js' %}"
></script>
<script
type="text/javascript"
type="module"
src="{% static 'js/scene.bundle.js' %}"
></script>
<script
type="text/javascript"
type="module"
src="{% static 'js/editor.bundle.js' %}"
></script>
</head>
<body>
<quackscape-scene
scene="{{ scene.id }}"
x="{{ scene.default_x }}"
y="{{ scene.default_y }}"
></quackscape-scene>
<div
class="modal fade"
id="editModal"
tabindex="-1"
aria-labelledby="editModalLabel"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="editModalLabel"></h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div id="editModalContent" class="modal-body">...</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Close
</button>
<button
id="editModalSave"
style="display: none"
type="button"
class="btn btn-primary"
>
Save changes
</button>
</div>
</div>
</div>
</div>
</body>
</html>

View file

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

16
quackscape/tours/urls.py Normal file
View file

@ -0,0 +1,16 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import SceneAPIViewSet, SceneView, SceneEditView, SceneEmbedView, ElementAPIViewSet
router = DefaultRouter()
router.register(r'scenes', SceneAPIViewSet, "scene")
router.register(r'scene/(?P<scene>[^/.]+)/elements', ElementAPIViewSet, "element")
urlpatterns = [
path('api/', include(router.urls)),
path('scene/<uuid:pk>/', SceneView.as_view(), name='scene'),
path('scene/<uuid:pk>/embed/', SceneEmbedView.as_view(), name='scene-embed'),
path('scene/<uuid:pk>/edit/', SceneEditView.as_view(), name='scene-edit'),
]

70
quackscape/tours/views.py Normal file
View file

@ -0,0 +1,70 @@
from django.views.generic import TemplateView, DetailView
from django.views.decorators.clickjacking import xframe_options_exempt
from django.utils.decorators import method_decorator
from django.core.exceptions import PermissionDenied
from rest_framework import viewsets
from .models import Scene, Element
from .serializers import SceneSerializer, ElementPolymorphicSerializer
class UserPermissionMixin:
def get_object(self, queryset=None):
obj = super().get_object(queryset)
if obj.user_has_permission(self.request.user):
return obj
raise PermissionDenied()
class PublicPermissionMixin:
def get_object(self, queryset=None):
obj = super().get_object(queryset)
if obj.public or obj.user_has_permission(self.request.user):
return obj
raise PermissionDenied()
class SceneAPIViewSet(viewsets.GenericViewSet, viewsets.mixins.RetrieveModelMixin):
serializer_class = SceneSerializer
queryset = Scene.objects.all()
def get_object(self):
obj = super().get_object()
if not (obj.public or obj.user_has_permission(self.request.user)):
raise PermissionDenied()
return obj
class ElementAPIViewSet(viewsets.ModelViewSet):
serializer_class = ElementPolymorphicSerializer
def get_queryset(self):
scene = Scene.objects.get(id=self.kwargs["scene"])
if not scene.user_has_permission(self.request.user):
raise PermissionDenied
return scene.elements
class SceneView(PublicPermissionMixin, DetailView):
model = Scene
context_object_name = "scene"
template_name = "tours/scene.html"
class SceneEditView(UserPermissionMixin, DetailView):
model = Scene
context_object_name = "scene"
template_name = "tours/scene_edit.html"
@method_decorator(xframe_options_exempt, name="dispatch")
class SceneEmbedView(SceneView):
pass

14
quackscape/urls.py Normal file
View file

@ -0,0 +1,14 @@
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
urlpatterns = [
path("admin/", admin.site.urls),
path("tours/", include("quackscape.tours.urls")),
path('api/', SpectacularAPIView.as_view(), name='schema'),
path('api/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

View file

@ -0,0 +1,5 @@
from django.contrib import admin
from .models import QuackUser
admin.site.register(QuackUser)

6
quackscape/users/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'quackscape.users'

View file

@ -0,0 +1,32 @@
# Generated by Django 5.0.3 on 2024-03-07 19:09
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='QuackUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'abstract': False,
},
),
]

View file

View file

@ -0,0 +1,37 @@
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils.translation import gettext_lazy as _
class QuackUserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields):
if not email:
raise ValueError(_('The Email field must be set'))
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
if extra_fields.get('is_staff') is not True:
raise ValueError(_('Superuser must have is_staff=True.'))
if extra_fields.get('is_superuser') is not True:
raise ValueError(_('Superuser must have is_superuser=True.'))
return self.create_user(email, password, **extra_fields)
class QuackUser(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(_('email address'), unique=True)
is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
objects = QuackUserManager()
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
def __str__(self):
return self.email

View file

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

View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

16
quackscape/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for quackscape project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'quackscape.settings')
application = get_wsgi_application()

22
requirements.txt Normal file
View file

@ -0,0 +1,22 @@
django
djangorestframework
django-storages
django-polymorphic
mysqlclient
setuptools
pillow
pygments
markdown
coreapi
pyyaml
django-autosecretkey
celery
redis
django-celery-results
django-celery-beat
drf-spectacular[sidecar]
boto3
psycopg2
argon2-cffi
django-csp
django-rest-polymorphic

62
webpack.config.js Normal file
View file

@ -0,0 +1,62 @@
const path = require("path");
const miniCssExtractPlugin = require("mini-css-extract-plugin");
const autoprefixer = require('autoprefixer');
module.exports = {
entry: {
api: "./assets/js/api.js",
scene: "./assets/js/scene.js",
editor: "./assets/js/editor.js",
frontend: "./assets/js/frontend.js",
},
output: {
path: path.resolve(__dirname, "static/js"),
filename: "[name].bundle.js",
},
plugins: [new miniCssExtractPlugin()],
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
},
},
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
{
test: /\.scss$/,
use: [
{
// Adds CSS to the DOM by injecting a `<style>` tag
loader: "style-loader",
},
{
// Interprets `@import` and `url()` like `import/require()` and will resolve them
loader: "css-loader",
},
{
// Loader for webpack to process CSS with PostCSS
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: [autoprefixer],
},
},
},
{
// Loads a SASS/SCSS file and compiles it to CSS
loader: "sass-loader",
},
],
},
],
},
};