A mo-mo-mo-monster
This commit is contained in:
commit
d6e76e8cfd
56 changed files with 8523 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal 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
12
README.md
Normal 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
40
assets/js/api.js
Normal 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
173
assets/js/editor.js
Normal 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
1
assets/js/frontend.js
Normal file
|
@ -0,0 +1 @@
|
|||
import '../scss/frontend.scss';
|
124
assets/js/scene.js
Normal file
124
assets/js/scene.js
Normal 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 };
|
2
assets/scss/frontend.scss
Normal file
2
assets/scss/frontend.scss
Normal file
|
@ -0,0 +1,2 @@
|
|||
@import "bootstrap/scss/bootstrap";
|
||||
|
22
manage.py
Executable file
22
manage.py
Executable 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
6458
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal 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
3
quackscape/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ['celery_app']
|
16
quackscape/asgi.py
Normal file
16
quackscape/asgi.py
Normal 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
9
quackscape/celery.py
Normal 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()
|
0
quackscape/management/commands/__init__.py
Normal file
0
quackscape/management/commands/__init__.py
Normal file
15
quackscape/management/commands/build.py
Normal file
15
quackscape/management/commands/build.py
Normal 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.'))
|
31
quackscape/management/commands/rundev.py
Normal file
31
quackscape/management/commands/rundev.py
Normal 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...")
|
14
quackscape/management/commands/runworker.py
Normal file
14
quackscape/management/commands/runworker.py
Normal 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
238
quackscape/settings.py
Normal 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
|
0
quackscape/tours/__init__.py
Normal file
0
quackscape/tours/__init__.py
Normal file
12
quackscape/tours/admin.py
Normal file
12
quackscape/tours/admin.py
Normal 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
6
quackscape/tours/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ToursConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'quackscape.tours'
|
0
quackscape/tours/management/commands/__init__.py
Normal file
0
quackscape/tours/management/commands/__init__.py
Normal file
14
quackscape/tours/management/commands/createresolutions.py
Normal file
14
quackscape/tours/management/commands/createresolutions.py
Normal 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)
|
101
quackscape/tours/migrations/0001_initial.py
Normal file
101
quackscape/tours/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
|
@ -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',),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
19
quackscape/tours/migrations/0004_alter_originalmedia_file.py
Normal file
19
quackscape/tours/migrations/0004_alter_originalmedia_file.py
Normal 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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
0
quackscape/tours/migrations/__init__.py
Normal file
0
quackscape/tours/migrations/__init__.py
Normal file
255
quackscape/tours/models.py
Normal file
255
quackscape/tours/models.py
Normal 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
|
84
quackscape/tours/serializers.py
Normal file
84
quackscape/tours/serializers.py
Normal 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
84
quackscape/tours/tasks.py
Normal 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()
|
12
quackscape/tours/templates/tours/scene.html
Normal file
12
quackscape/tours/templates/tours/scene.html
Normal 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>
|
74
quackscape/tours/templates/tours/scene_edit.html
Normal file
74
quackscape/tours/templates/tours/scene_edit.html
Normal 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>
|
3
quackscape/tours/tests.py
Normal file
3
quackscape/tours/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
16
quackscape/tours/urls.py
Normal file
16
quackscape/tours/urls.py
Normal 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
70
quackscape/tours/views.py
Normal 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
14
quackscape/urls.py
Normal 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)
|
0
quackscape/users/__init__.py
Normal file
0
quackscape/users/__init__.py
Normal file
5
quackscape/users/admin.py
Normal file
5
quackscape/users/admin.py
Normal 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
6
quackscape/users/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'quackscape.users'
|
32
quackscape/users/migrations/0001_initial.py
Normal file
32
quackscape/users/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
0
quackscape/users/migrations/__init__.py
Normal file
0
quackscape/users/migrations/__init__.py
Normal file
37
quackscape/users/models.py
Normal file
37
quackscape/users/models.py
Normal 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
|
3
quackscape/users/tests.py
Normal file
3
quackscape/users/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
3
quackscape/users/views.py
Normal file
3
quackscape/users/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
16
quackscape/wsgi.py
Normal file
16
quackscape/wsgi.py
Normal 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
22
requirements.txt
Normal 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
62
webpack.config.js
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
Loading…
Reference in a new issue