From dbf7cde1830a300a7a588b49d9e89c5e9155f52e Mon Sep 17 00:00:00 2001 From: Kumi Date: Sat, 22 Jun 2024 18:32:37 +0200 Subject: [PATCH] feat!: initial FreeDOI project setup - Added standard project documentation including README.md - Added .gitignore for ignoring unnecessary files (.venv, *.pyc, etc.) - Implemented basic Django application structure with accounts and resolver apps - Configured Django settings, including two-factor auth and database setup - Set up Django admin and basic model structures for Prefixes, Suffixes, Identifiers, and Permissions - Added templates for accounts and resolver management - Configured initial migrations and custom user model - Included poetry dependencies and project setup configuration This commit sets up the fundamental structure of the FreeDOI project, enabling DOI-like identifier creation and resolution. --- .gitignore | 9 + .vscode/launch.json | 17 ++ LICENSE | 19 ++ README.md | 29 ++ freedoi/__init__.py | 0 freedoi/accounts/__init__.py | 0 freedoi/accounts/admin.py | 3 + freedoi/accounts/apps.py | 6 + freedoi/accounts/forms.py | 4 + freedoi/accounts/migrations/0001_initial.py | 74 +++++ .../0002_customuser_maximum_suffixes.py | 18 ++ freedoi/accounts/migrations/__init__.py | 0 freedoi/accounts/models.py | 39 +++ .../templates/accounts/email_sent.html | 9 + .../accounts/templates/accounts/login.html | 10 + .../templates/accounts/send_login_email.html | 10 + freedoi/accounts/tests.py | 3 + freedoi/accounts/urls.py | 20 ++ freedoi/accounts/views.py | 46 +++ freedoi/asgi.py | 16 ++ freedoi/manage.py | 22 ++ freedoi/resolver/__init__.py | 0 freedoi/resolver/admin.py | 36 +++ freedoi/resolver/apps.py | 6 + freedoi/resolver/forms.py | 53 ++++ freedoi/resolver/migrations/0001_initial.py | 96 +++++++ .../resolver/migrations/0002_permission.py | 75 +++++ .../0003_prefix_owner_suffix_owner.py | 36 +++ .../0004_prefix_public_suffix_approved.py | 23 ++ ...ter_prefix_options_alter_suffix_options.py | 21 ++ freedoi/resolver/migrations/__init__.py | 0 freedoi/resolver/models.py | 111 ++++++++ freedoi/resolver/templates/base_generic.html | 67 +++++ freedoi/resolver/templates/resolver/home.html | 21 ++ .../resolver/identifier_confirm_delete.html | 9 + .../templates/resolver/identifier_detail.html | 11 + .../templates/resolver/identifier_form.html | 8 + .../templates/resolver/identifier_list.html | 13 + .../resolver/prefix_confirm_delete.html | 10 + .../templates/resolver/prefix_detail.html | 17 ++ .../templates/resolver/prefix_form.html | 10 + .../templates/resolver/prefix_list.html | 16 ++ .../resolver/suffix_confirm_delete.html | 9 + .../templates/resolver/suffix_detail.html | 23 ++ .../templates/resolver/suffix_form.html | 8 + .../templates/resolver/suffix_list.html | 9 + freedoi/resolver/tests.py | 3 + freedoi/resolver/urls.py | 60 ++++ freedoi/resolver/views.py | 269 ++++++++++++++++++ freedoi/settings.ini.template | 3 + freedoi/settings.py | 121 ++++++++ freedoi/urls.py | 11 + freedoi/wsgi.py | 16 ++ pyproject.toml | 46 +++ 54 files changed, 1571 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 freedoi/__init__.py create mode 100644 freedoi/accounts/__init__.py create mode 100644 freedoi/accounts/admin.py create mode 100644 freedoi/accounts/apps.py create mode 100644 freedoi/accounts/forms.py create mode 100644 freedoi/accounts/migrations/0001_initial.py create mode 100644 freedoi/accounts/migrations/0002_customuser_maximum_suffixes.py create mode 100644 freedoi/accounts/migrations/__init__.py create mode 100644 freedoi/accounts/models.py create mode 100644 freedoi/accounts/templates/accounts/email_sent.html create mode 100644 freedoi/accounts/templates/accounts/login.html create mode 100644 freedoi/accounts/templates/accounts/send_login_email.html create mode 100644 freedoi/accounts/tests.py create mode 100644 freedoi/accounts/urls.py create mode 100644 freedoi/accounts/views.py create mode 100644 freedoi/asgi.py create mode 100755 freedoi/manage.py create mode 100644 freedoi/resolver/__init__.py create mode 100644 freedoi/resolver/admin.py create mode 100644 freedoi/resolver/apps.py create mode 100644 freedoi/resolver/forms.py create mode 100644 freedoi/resolver/migrations/0001_initial.py create mode 100644 freedoi/resolver/migrations/0002_permission.py create mode 100644 freedoi/resolver/migrations/0003_prefix_owner_suffix_owner.py create mode 100644 freedoi/resolver/migrations/0004_prefix_public_suffix_approved.py create mode 100644 freedoi/resolver/migrations/0005_alter_prefix_options_alter_suffix_options.py create mode 100644 freedoi/resolver/migrations/__init__.py create mode 100644 freedoi/resolver/models.py create mode 100644 freedoi/resolver/templates/base_generic.html create mode 100644 freedoi/resolver/templates/resolver/home.html create mode 100644 freedoi/resolver/templates/resolver/identifier_confirm_delete.html create mode 100644 freedoi/resolver/templates/resolver/identifier_detail.html create mode 100644 freedoi/resolver/templates/resolver/identifier_form.html create mode 100644 freedoi/resolver/templates/resolver/identifier_list.html create mode 100644 freedoi/resolver/templates/resolver/prefix_confirm_delete.html create mode 100644 freedoi/resolver/templates/resolver/prefix_detail.html create mode 100644 freedoi/resolver/templates/resolver/prefix_form.html create mode 100644 freedoi/resolver/templates/resolver/prefix_list.html create mode 100644 freedoi/resolver/templates/resolver/suffix_confirm_delete.html create mode 100644 freedoi/resolver/templates/resolver/suffix_detail.html create mode 100644 freedoi/resolver/templates/resolver/suffix_form.html create mode 100644 freedoi/resolver/templates/resolver/suffix_list.html create mode 100644 freedoi/resolver/tests.py create mode 100644 freedoi/resolver/urls.py create mode 100644 freedoi/resolver/views.py create mode 100644 freedoi/settings.ini.template create mode 100644 freedoi/settings.py create mode 100644 freedoi/urls.py create mode 100644 freedoi/wsgi.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..165c76e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +settings.ini +*.pyc +__pycache__/ +db.sqlite3 +node_modules/ +static/js/* +media/ +.venv/ +venv/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6e51320 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "args": [ + "runserver", + "8108" + ], + "django": true, + "autoStartBrowser": false, + "program": "${workspaceFolder}/freedoi/manage.py" + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3788438 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Private.coffee Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb1ae1d --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# FreeDOI + +FreeDOI is where the concept of freedom meets the concept of DOI. It is a free and open-source service that allows you to create DOI-like identifiers for your digital objects. The identifiers are designed to be persistent and resolvable, and they can be used to cite your digital objects in any context. + +## How it works + +Whereas a DOI is a unique identifier that is assigned to a digital object by a registration agency in scholarly contexts for a fee, a FreeDOI is a unique identifier that you can create for your digital object of any kind for free. Whether you are a researcher, a developer, a designer, a writer, or a creator of any kind, you can use FreeDOI to create a FreeDOI for your digital object and share it with others. Your domain changes, your hosting changes, your URL changes, but your FreeDOI can be updated to point to the new location of your digital object. + +### Making a FreeDOI + +FreeDOIs are created by combining three elements: a prefix, a suffix and an identifier. + +While all "original" DOIs start with a prefix of `10.`, FreeDOIs start with a prefix between `20.` and `29.`, which roughly identifies the type of digital object. The prefix is followed by a suffix, which is a unique number of at least four digits, assigned to you by FreeDOI. Lastly, the identifier is a alphanumeric string that uniquely identifies your digital object. + +For example, if you are a researcher, you can request a suffix under the prefix `20.`. Let's say you are assigned the suffix `1234`. You can then create a FreeDOI for your research paper with the identifier `my-paper`. The resulting FreeDOI would be `20.1234/my-paper`. Add the URL of your digital object to the FreeDOI, and you have a persistent identifier that you can use to cite your research paper - because if the URL changes, you can simply update it on FreeDOI to point to the new location of your research paper. + +#### Remote resolution + +If you're a larger organization, you may want to operate your own resolver for your suffix. In this case, instead of setting up identifiers in your FreeDOI account, you can simply configure your entire suffix to point to your resolver. If you're assigned `20.1234`, a request to `https://freedoi.org/20.1234/my-paper` might redirect to `https://resolver.your-organization.org/my-paper`, where you can handle the final resolution of the identifier. + +Note that we strongly recommend that you use the FreeDOI service for setting up identifiers instead of operating your own resolver, as we will take care of the maintenance and uptime of the resolver for you. We have APIs in place that allow you to programmatically create and update FreeDOIs in addition to the web interface. + +### Resolving a FreeDOI + +To resolve a FreeDOI, simply visit `https://freedoi.org/20.1234/my-paper` in your browser. The FreeDOI resolver will redirect you to the URL of the digital object that is associated with the FreeDOI. + +## License + +FreeDOI is licensed under the [MIT License](LICENSE). \ No newline at end of file diff --git a/freedoi/__init__.py b/freedoi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freedoi/accounts/__init__.py b/freedoi/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freedoi/accounts/admin.py b/freedoi/accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/freedoi/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/freedoi/accounts/apps.py b/freedoi/accounts/apps.py new file mode 100644 index 0000000..6fb4d4c --- /dev/null +++ b/freedoi/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "freedoi.accounts" diff --git a/freedoi/accounts/forms.py b/freedoi/accounts/forms.py new file mode 100644 index 0000000..26a01b8 --- /dev/null +++ b/freedoi/accounts/forms.py @@ -0,0 +1,4 @@ +from django import forms + +class EmailForm(forms.Form): + email = forms.EmailField() \ No newline at end of file diff --git a/freedoi/accounts/migrations/0001_initial.py b/freedoi/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..0582591 --- /dev/null +++ b/freedoi/accounts/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 5.0.6 on 2024-06-22 13:31 + +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="CustomUser", + 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)), + ("first_name", models.CharField(blank=True, max_length=30)), + ("last_name", models.CharField(blank=True, max_length=30)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ( + "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, + }, + ), + ] diff --git a/freedoi/accounts/migrations/0002_customuser_maximum_suffixes.py b/freedoi/accounts/migrations/0002_customuser_maximum_suffixes.py new file mode 100644 index 0000000..6a434f4 --- /dev/null +++ b/freedoi/accounts/migrations/0002_customuser_maximum_suffixes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-06-22 15:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="customuser", + name="maximum_suffixes", + field=models.IntegerField(default=5), + ), + ] diff --git a/freedoi/accounts/migrations/__init__.py b/freedoi/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freedoi/accounts/models.py b/freedoi/accounts/models.py new file mode 100644 index 0000000..7e16d7c --- /dev/null +++ b/freedoi/accounts/models.py @@ -0,0 +1,39 @@ +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.db import models + +class CustomUserManager(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 CustomUser(AbstractBaseUser, PermissionsMixin): + email = models.EmailField(unique=True) + first_name = models.CharField(max_length=30, blank=True) + last_name = models.CharField(max_length=30, blank=True) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + maximum_suffixes = models.IntegerField(default=5) + + objects = CustomUserManager() + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] + + def __str__(self): + return self.email \ No newline at end of file diff --git a/freedoi/accounts/templates/accounts/email_sent.html b/freedoi/accounts/templates/accounts/email_sent.html new file mode 100644 index 0000000..a77bff6 --- /dev/null +++ b/freedoi/accounts/templates/accounts/email_sent.html @@ -0,0 +1,9 @@ +{% extends "base_generic.html" %} {% block title %}Email Sent{% endblock %} +{% block content %} +
+

Email Sent

+

+ A login link has been sent to your email address. Please check your inbox. +

+
+{% endblock %} diff --git a/freedoi/accounts/templates/accounts/login.html b/freedoi/accounts/templates/accounts/login.html new file mode 100644 index 0000000..3c34f9d --- /dev/null +++ b/freedoi/accounts/templates/accounts/login.html @@ -0,0 +1,10 @@ +{% extends "base_generic.html" %} {% block title %}Login{% endblock %} +{% block content %} +
+

Login

+
+ {% csrf_token %} {{ form.as_p }} + +
+
+{% endblock %} diff --git a/freedoi/accounts/templates/accounts/send_login_email.html b/freedoi/accounts/templates/accounts/send_login_email.html new file mode 100644 index 0000000..b6c911f --- /dev/null +++ b/freedoi/accounts/templates/accounts/send_login_email.html @@ -0,0 +1,10 @@ +{% extends "base_generic.html" %} {% block title %}Email Login{% endblock %} +{% block content %} +
+

Email Login

+
+ {% csrf_token %} {{ form.as_p }} + +
+
+{% endblock %} diff --git a/freedoi/accounts/tests.py b/freedoi/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/freedoi/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/freedoi/accounts/urls.py b/freedoi/accounts/urls.py new file mode 100644 index 0000000..4237404 --- /dev/null +++ b/freedoi/accounts/urls.py @@ -0,0 +1,20 @@ +from django.urls import path +from django.contrib.auth import views as auth_views +from django.views.generic import TemplateView +from .views import SendLoginEmailView, LoginView + +urlpatterns = [ + path("send-login-email/", SendLoginEmailView.as_view(), name="send_login_email"), + path("login///", LoginView.as_view(), name="login"), + path( + "login/", + auth_views.LoginView.as_view(template_name="accounts/login.html"), + name="login", + ), + path("logout/", auth_views.LogoutView.as_view(), name="logout"), + path( + "email-sent/", + TemplateView.as_view(template_name="accounts/email_sent.html"), + name="email_sent", + ), +] diff --git a/freedoi/accounts/views.py b/freedoi/accounts/views.py new file mode 100644 index 0000000..1567954 --- /dev/null +++ b/freedoi/accounts/views.py @@ -0,0 +1,46 @@ +from django.contrib.auth import get_user_model, login as auth_login +from django.core.mail import send_mail +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes, force_str +from django.template.loader import render_to_string +from django.http import HttpResponse +from django.shortcuts import redirect +from django.views.generic import FormView, View +from django.contrib.auth.tokens import default_token_generator +from .forms import EmailForm + +User = get_user_model() + +class SendLoginEmailView(FormView): + template_name = 'accounts/send_login_email.html' + form_class = EmailForm + success_url = '/accounts/email-sent/' + + def form_valid(self, form): + email = form.cleaned_data['email'] + user = User.objects.get(email=email) + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + login_url = self.request.build_absolute_uri(f'/accounts/login/{uid}/{token}/') + send_mail( + 'Your login link', + f'Click here to log in: {login_url}', + 'from@example.com', + [email], + ) + return super().form_valid(form) + +class LoginView(View): + def get(self, request, uidb64, token): + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + user = None + + if user is not None and default_token_generator.check_token(user, token): + user.backend = 'django.contrib.auth.backends.ModelBackend' + auth_login(request, user) + return redirect('home') + else: + return HttpResponse('Login link is invalid') \ No newline at end of file diff --git a/freedoi/asgi.py b/freedoi/asgi.py new file mode 100644 index 0000000..1af8ac2 --- /dev/null +++ b/freedoi/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for freedoi 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', 'freedoi.settings') + +application = get_asgi_application() diff --git a/freedoi/manage.py b/freedoi/manage.py new file mode 100755 index 0000000..9d1aaab --- /dev/null +++ b/freedoi/manage.py @@ -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', 'freedoi.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() diff --git a/freedoi/resolver/__init__.py b/freedoi/resolver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freedoi/resolver/admin.py b/freedoi/resolver/admin.py new file mode 100644 index 0000000..1db93f0 --- /dev/null +++ b/freedoi/resolver/admin.py @@ -0,0 +1,36 @@ +from django.contrib import admin + +from .models import Prefix, Suffix, Identifier, Permission + + +@admin.register(Prefix) +class PrefixAdmin(admin.ModelAdmin): + list_display = ("name", "prefix", "type", "remote_resolver") + search_fields = ("name", "prefix") + list_filter = ("type",) + + +@admin.register(Suffix) +class SuffixAdmin(admin.ModelAdmin): + list_display = ("name", "prefix", "suffix", "type", "remote_resolver") + search_fields = ("name", "suffix") + list_filter = ("type", "prefix") + + +@admin.register(Identifier) +class IdentifierAdmin(admin.ModelAdmin): + list_display = ("suffix", "identifier", "target_url") + search_fields = ("suffix__suffix", "identifier") + list_filter = ("suffix__prefix",) + + +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + list_display = ("user", "permission_type", "prefix", "suffix", "identifier") + search_fields = ( + "user__email", + "prefix__prefix", + "suffix__suffix", + "identifier__identifier", + ) + list_filter = ("permission_type", "prefix", "suffix", "identifier") diff --git a/freedoi/resolver/apps.py b/freedoi/resolver/apps.py new file mode 100644 index 0000000..d0496ba --- /dev/null +++ b/freedoi/resolver/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ResolverConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'freedoi.resolver' diff --git a/freedoi/resolver/forms.py b/freedoi/resolver/forms.py new file mode 100644 index 0000000..629c0e1 --- /dev/null +++ b/freedoi/resolver/forms.py @@ -0,0 +1,53 @@ +from django import forms +from django.db.models import Q +from .models import Prefix, Suffix, Identifier, Permission + + +class PrefixForm(forms.ModelForm): + class Meta: + model = Prefix + fields = ["name", "prefix", "public", "type", "remote_resolver"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + self.fields["prefix"].widget.attrs["readonly"] = True + + +class SuffixForm(forms.ModelForm): + class Meta: + model = Suffix + fields = ["name", "prefix", "suffix", "type", "remote_resolver"] + + def __init__(self, *args, **kwargs): + user = kwargs.pop("user") + super().__init__(*args, **kwargs) + + self.fields["prefix"].queryset = Prefix.objects.filter( + Q(type="local") & (Q(owner=user) | Q(public=True)) + ) + + if self.instance.pk: + self.fields["suffix"].widget.attrs["readonly"] = True + self.fields["prefix"].widget.attrs["readonly"] = True + + +class IdentifierForm(forms.ModelForm): + class Meta: + model = Identifier + fields = ["suffix", "identifier", "target_url"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["suffix"].queryset = Suffix.objects.filter(type="local") + + if self.instance.pk: + self.fields["identifier"].widget.attrs["readonly"] = True + self.fields["suffix"].widget.attrs["readonly"] = True + + +class PermissionForm(forms.ModelForm): + class Meta: + model = Permission + fields = ["user", "prefix", "suffix", "permission_type"] diff --git a/freedoi/resolver/migrations/0001_initial.py b/freedoi/resolver/migrations/0001_initial.py new file mode 100644 index 0000000..f364128 --- /dev/null +++ b/freedoi/resolver/migrations/0001_initial.py @@ -0,0 +1,96 @@ +# Generated by Django 5.0.6 on 2024-06-22 12:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Prefix", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50, unique=True)), + ("prefix", models.CharField(max_length=10, unique=True)), + ( + "type", + models.CharField( + choices=[("local", "Local"), ("remote", "Remote")], max_length=6 + ), + ), + ("remote_resolver", models.URLField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name="Suffix", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50)), + ("suffix", models.CharField(max_length=100)), + ( + "type", + models.CharField( + choices=[("local", "Local"), ("remote", "Remote")], max_length=6 + ), + ), + ("remote_resolver", models.URLField(blank=True, null=True)), + ( + "prefix", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="resolver.prefix", + ), + ), + ], + options={ + "unique_together": {("prefix", "suffix")}, + }, + ), + migrations.CreateModel( + name="Identifier", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("identifier", models.CharField(max_length=100)), + ("target_url", models.URLField()), + ( + "suffix", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="resolver.suffix", + ), + ), + ], + options={ + "unique_together": {("suffix", "identifier")}, + }, + ), + ] diff --git a/freedoi/resolver/migrations/0002_permission.py b/freedoi/resolver/migrations/0002_permission.py new file mode 100644 index 0000000..b924a0e --- /dev/null +++ b/freedoi/resolver/migrations/0002_permission.py @@ -0,0 +1,75 @@ +# Generated by Django 5.0.6 on 2024-06-22 13:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resolver", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Permission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "permission_type", + models.CharField( + choices=[("read", "Read"), ("write", "Write")], max_length=5 + ), + ), + ( + "identifier", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="resolver.identifier", + ), + ), + ( + "prefix", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="resolver.prefix", + ), + ), + ( + "suffix", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="resolver.suffix", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": { + ("user", "prefix", "suffix", "identifier", "permission_type") + }, + }, + ), + ] diff --git a/freedoi/resolver/migrations/0003_prefix_owner_suffix_owner.py b/freedoi/resolver/migrations/0003_prefix_owner_suffix_owner.py new file mode 100644 index 0000000..1d6bcd0 --- /dev/null +++ b/freedoi/resolver/migrations/0003_prefix_owner_suffix_owner.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.6 on 2024-06-22 14:21 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resolver", "0002_permission"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="prefix", + name="owner", + field=models.ForeignKey( + default=0, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="suffix", + name="owner", + field=models.ForeignKey( + default=0, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + ] diff --git a/freedoi/resolver/migrations/0004_prefix_public_suffix_approved.py b/freedoi/resolver/migrations/0004_prefix_public_suffix_approved.py new file mode 100644 index 0000000..190762c --- /dev/null +++ b/freedoi/resolver/migrations/0004_prefix_public_suffix_approved.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-06-22 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resolver", "0003_prefix_owner_suffix_owner"), + ] + + operations = [ + migrations.AddField( + model_name="prefix", + name="public", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="suffix", + name="approved", + field=models.BooleanField(default=False), + ), + ] diff --git a/freedoi/resolver/migrations/0005_alter_prefix_options_alter_suffix_options.py b/freedoi/resolver/migrations/0005_alter_prefix_options_alter_suffix_options.py new file mode 100644 index 0000000..7667197 --- /dev/null +++ b/freedoi/resolver/migrations/0005_alter_prefix_options_alter_suffix_options.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.6 on 2024-06-22 15:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("resolver", "0004_prefix_public_suffix_approved"), + ] + + operations = [ + migrations.AlterModelOptions( + name="prefix", + options={"ordering": ["prefix"]}, + ), + migrations.AlterModelOptions( + name="suffix", + options={"ordering": ["suffix"]}, + ), + ] diff --git a/freedoi/resolver/migrations/__init__.py b/freedoi/resolver/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freedoi/resolver/models.py b/freedoi/resolver/models.py new file mode 100644 index 0000000..e5c73cc --- /dev/null +++ b/freedoi/resolver/models.py @@ -0,0 +1,111 @@ +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class Prefix(models.Model): + PREFIX_TYPES = [ + ("local", "Local"), + ("remote", "Remote"), + ] + + name = models.CharField(max_length=50, unique=True) + prefix = models.CharField(max_length=10, unique=True) + owner = models.ForeignKey(User, on_delete=models.PROTECT) + type = models.CharField(max_length=6, choices=PREFIX_TYPES) + public = models.BooleanField(default=True) + remote_resolver = models.URLField(blank=True, null=True) + + def __str__(self): + return f"{self.prefix} ({self.name})" + + class Meta: + ordering = ["prefix"] + + def save(self, *args, **kwargs): + if self.pk is not None: + old = Prefix.objects.get(pk=self.pk) + if old.prefix != self.prefix: + raise ValueError("Prefix cannot be changed") + + super().save(*args, **kwargs) + + +class Suffix(models.Model): + SUFFIX_TYPES = [ + ("local", "Local"), + ("remote", "Remote"), + ] + + name = models.CharField(max_length=50) + suffix = models.CharField(max_length=100) + prefix = models.ForeignKey(Prefix, on_delete=models.PROTECT) + owner = models.ForeignKey(User, on_delete=models.CASCADE) + approved = models.BooleanField(default=False) + type = models.CharField(max_length=6, choices=SUFFIX_TYPES) + remote_resolver = models.URLField(blank=True, null=True) + + class Meta: + unique_together = ("prefix", "suffix") + ordering = ["prefix", "suffix"] + + def __str__(self): + return f"{self.prefix.prefix}.{self.suffix} ({self.name})" + + def save(self, *args, **kwargs): + if self.pk is not None: + old = Suffix.objects.get(pk=self.pk) + if old.suffix != self.suffix: + raise ValueError("Suffix cannot be changed") + if old.prefix != self.prefix: + raise ValueError("Prefix cannot be changed") + + super().save(*args, **kwargs) + + +class Identifier(models.Model): + suffix = models.ForeignKey(Suffix, on_delete=models.CASCADE) + identifier = models.CharField(max_length=100) + target_url = models.URLField() + + class Meta: + unique_together = ("suffix", "identifier") + + def __str__(self): + return f"{self.suffix.prefix.prefix}.{self.suffix.suffix}/{self.identifier}" + + @property + def owner(self): + return self.suffix.owner + + def save(self, *args, **kwargs): + if self.pk is not None: + old = Identifier.objects.get(pk=self.pk) + if old.identifier != self.identifier: + raise ValueError("Identifier cannot be changed") + if old.suffix != self.suffix: + raise ValueError("Suffix cannot be changed") + + super().save(*args, **kwargs) + + +class Permission(models.Model): + PERMISSION_TYPES = [ + ("read", "Read"), + ("write", "Write"), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE) + prefix = models.ForeignKey(Prefix, on_delete=models.CASCADE, null=True, blank=True) + suffix = models.ForeignKey(Suffix, on_delete=models.CASCADE, null=True, blank=True) + identifier = models.ForeignKey( + Identifier, on_delete=models.CASCADE, null=True, blank=True + ) + permission_type = models.CharField(max_length=5, choices=PERMISSION_TYPES) + + class Meta: + unique_together = ("user", "prefix", "suffix", "identifier", "permission_type") + + def __str__(self): + return f"{self.user.email} - {self.permission_type} - {self.prefix or ''} {self.suffix or ''} {self.identifier or ''}" diff --git a/freedoi/resolver/templates/base_generic.html b/freedoi/resolver/templates/base_generic.html new file mode 100644 index 0000000..ee0f92d --- /dev/null +++ b/freedoi/resolver/templates/base_generic.html @@ -0,0 +1,67 @@ + + + + + + {% block title %}FreeDOI{% endblock %} + + + + + + +
{% block content %} {% endblock %}
+ + + + + + diff --git a/freedoi/resolver/templates/resolver/home.html b/freedoi/resolver/templates/resolver/home.html new file mode 100644 index 0000000..8ca6c2a --- /dev/null +++ b/freedoi/resolver/templates/resolver/home.html @@ -0,0 +1,21 @@ +{% extends "base_generic.html" %} {% block title %}Home{% endblock %} +{% block content %} +
+

Welcome to FreeDOI

+

This is a simple system for managing and resolving DOIs.

+
+

+ Use the navigation links above to manage prefixes, suffixes, and + identifiers. +

+ {% if user.is_authenticated %} + Manage Suffixes + {% else %} + Login + {% endif %} +
+{% endblock %} diff --git a/freedoi/resolver/templates/resolver/identifier_confirm_delete.html b/freedoi/resolver/templates/resolver/identifier_confirm_delete.html new file mode 100644 index 0000000..318fcc5 --- /dev/null +++ b/freedoi/resolver/templates/resolver/identifier_confirm_delete.html @@ -0,0 +1,9 @@ +{% extends "base_generic.html" %} {% block content %} +

Delete Identifier

+

Are you sure you want to delete "{{ object.identifier }}"?

+
+ {% csrf_token %} + +
+Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/identifier_detail.html b/freedoi/resolver/templates/resolver/identifier_detail.html new file mode 100644 index 0000000..fcbcc1b --- /dev/null +++ b/freedoi/resolver/templates/resolver/identifier_detail.html @@ -0,0 +1,11 @@ +{% extends "base_generic.html" %} {% block content %} +

{{ object.identifier }}

+

Suffix: {{ object.suffix }}

+

Target URL: {{ object.target_url }}

+Edit +
+ {% csrf_token %} + +
+Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/identifier_form.html b/freedoi/resolver/templates/resolver/identifier_form.html new file mode 100644 index 0000000..e77b5ac --- /dev/null +++ b/freedoi/resolver/templates/resolver/identifier_form.html @@ -0,0 +1,8 @@ +{% extends "base_generic.html" %} {% block content %} +

{% if form.instance.pk %}Edit{% else %}Create{% endif %} Identifier

+
+ {% csrf_token %} {{ form.as_p }} + +
+Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/identifier_list.html b/freedoi/resolver/templates/resolver/identifier_list.html new file mode 100644 index 0000000..6584ceb --- /dev/null +++ b/freedoi/resolver/templates/resolver/identifier_list.html @@ -0,0 +1,13 @@ +{% extends "base_generic.html" %} {% block content %} +

Identifiers

+ +Create new identifier +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/prefix_confirm_delete.html b/freedoi/resolver/templates/resolver/prefix_confirm_delete.html new file mode 100644 index 0000000..f58ffc1 --- /dev/null +++ b/freedoi/resolver/templates/resolver/prefix_confirm_delete.html @@ -0,0 +1,10 @@ +{% extends "base_generic.html" %} {% block title %}Delete Prefix{% endblock %} +{% block content %} +

Delete Prefix

+

Are you sure you want to delete "{{ object.name }}"?

+
+ {% csrf_token %} + +
+Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/prefix_detail.html b/freedoi/resolver/templates/resolver/prefix_detail.html new file mode 100644 index 0000000..62d22ca --- /dev/null +++ b/freedoi/resolver/templates/resolver/prefix_detail.html @@ -0,0 +1,17 @@ +{% extends "base_generic.html" %} {% block title %}Prefix Detail{% endblock %} +{% block content %} +

{{ object.name }}

+

Prefix: {{ object.prefix }}

+

Type: {{ object.get_type_display }}

+

Remote Resolver: {{ object.remote_resolver }}

+Edit +
+ {% csrf_token %} + +
+Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/prefix_form.html b/freedoi/resolver/templates/resolver/prefix_form.html new file mode 100644 index 0000000..e465765 --- /dev/null +++ b/freedoi/resolver/templates/resolver/prefix_form.html @@ -0,0 +1,10 @@ +{% extends "base_generic.html" %} {% block title %} +{% if form.instance.pk %}Edit{% else %}Create{% endif %} Prefix{% endblock %} +{% block content %} +

{% if form.instance.pk %}Edit{% else %}Create{% endif %} Prefix

+
+ {% csrf_token %} {{ form.as_p }} + +
+Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/prefix_list.html b/freedoi/resolver/templates/resolver/prefix_list.html new file mode 100644 index 0000000..50048e5 --- /dev/null +++ b/freedoi/resolver/templates/resolver/prefix_list.html @@ -0,0 +1,16 @@ +{% extends "base_generic.html" %} {% block title %}Prefixes{% endblock %} +{% block content %} +

Prefixes

+ +{% if user.is_superuser %} +Create new prefix +{% endif %} +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/suffix_confirm_delete.html b/freedoi/resolver/templates/resolver/suffix_confirm_delete.html new file mode 100644 index 0000000..dcb9ecb --- /dev/null +++ b/freedoi/resolver/templates/resolver/suffix_confirm_delete.html @@ -0,0 +1,9 @@ +{% extends "base_generic.html" %} {% block content %} +

Delete Suffix

+

Are you sure you want to delete "{{ object.name }}"?

+
+ {% csrf_token %} + +
+Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/suffix_detail.html b/freedoi/resolver/templates/resolver/suffix_detail.html new file mode 100644 index 0000000..dc04229 --- /dev/null +++ b/freedoi/resolver/templates/resolver/suffix_detail.html @@ -0,0 +1,23 @@ +{% extends "base_generic.html" %} {% block title %}Suffix Detail{% endblock %} +{% block content %} +

{{ object.name }}

+

Suffix: {{ object.suffix }}

+

Prefix: {{ object.prefix }}

+

Type: {{ object.get_type_display }}

+

Remote Resolver: {{ object.remote_resolver }}

+Edit +
+ {% csrf_token %} + +
+View Identifiers +Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/suffix_form.html b/freedoi/resolver/templates/resolver/suffix_form.html new file mode 100644 index 0000000..dc3534f --- /dev/null +++ b/freedoi/resolver/templates/resolver/suffix_form.html @@ -0,0 +1,8 @@ +{% extends "base_generic.html" %} {% block content %} +

{% if form.instance.pk %}Edit{% else %}Create{% endif %} Suffix

+
+ {% csrf_token %} {{ form.as_p }} + +
+Back to list +{% endblock %} diff --git a/freedoi/resolver/templates/resolver/suffix_list.html b/freedoi/resolver/templates/resolver/suffix_list.html new file mode 100644 index 0000000..47a1ded --- /dev/null +++ b/freedoi/resolver/templates/resolver/suffix_list.html @@ -0,0 +1,9 @@ +{% extends "base_generic.html" %} {% block content %} +

Suffixes

+ +Create new suffix +{% endblock %} diff --git a/freedoi/resolver/tests.py b/freedoi/resolver/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/freedoi/resolver/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/freedoi/resolver/urls.py b/freedoi/resolver/urls.py new file mode 100644 index 0000000..0132fa0 --- /dev/null +++ b/freedoi/resolver/urls.py @@ -0,0 +1,60 @@ +from django.urls import path +from .views import ( + resolve_doi, + HomeView, + PrefixListView, + PrefixDetailView, + PrefixCreateView, + PrefixUpdateView, + PrefixDeleteView, + SuffixListView, + SuffixDetailView, + SuffixCreateView, + SuffixUpdateView, + SuffixDeleteView, + IdentifierListView, + IdentifierDetailView, + IdentifierCreateView, + IdentifierUpdateView, + IdentifierDeleteView, + SuffixIdentifierListView, +) + +urlpatterns = [ + path("", HomeView.as_view(), name="home"), + path(".//", resolve_doi, name="resolve_doi"), + path("prefixes/", PrefixListView.as_view(), name="prefix_list"), + path("prefixes//", PrefixDetailView.as_view(), name="prefix_detail"), + path("prefixes/create/", PrefixCreateView.as_view(), name="prefix_create"), + path("prefixes//update/", PrefixUpdateView.as_view(), name="prefix_update"), + path("prefixes//delete/", PrefixDeleteView.as_view(), name="prefix_delete"), + path("suffixes/", SuffixListView.as_view(), name="suffix_list"), + path("suffixes//", SuffixDetailView.as_view(), name="suffix_detail"), + path("suffixes/create/", SuffixCreateView.as_view(), name="suffix_create"), + path("suffixes//update/", SuffixUpdateView.as_view(), name="suffix_update"), + path("suffixes//delete/", SuffixDeleteView.as_view(), name="suffix_delete"), + path("identifiers/", IdentifierListView.as_view(), name="identifier_list"), + path( + "identifiers//", + IdentifierDetailView.as_view(), + name="identifier_detail", + ), + path( + "identifiers/create/", IdentifierCreateView.as_view(), name="identifier_create" + ), + path( + "identifiers//update/", + IdentifierUpdateView.as_view(), + name="identifier_update", + ), + path( + "identifiers//delete/", + IdentifierDeleteView.as_view(), + name="identifier_delete", + ), + path('suffixes//identifiers/', SuffixIdentifierListView.as_view(), name='identifier_list'), + path('suffixes//identifiers//', IdentifierDetailView.as_view(), name='identifier_detail'), + path('suffixes//identifiers/create/', IdentifierCreateView.as_view(), name='identifier_create'), + path('suffixes//identifiers//update/', IdentifierUpdateView.as_view(), name='identifier_update'), + path('suffixes//identifiers//delete/', IdentifierDeleteView.as_view(), name='identifier_delete'), +] diff --git a/freedoi/resolver/views.py b/freedoi/resolver/views.py new file mode 100644 index 0000000..6167d44 --- /dev/null +++ b/freedoi/resolver/views.py @@ -0,0 +1,269 @@ +from django.urls import reverse_lazy +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import ( + ListView, + DetailView, + CreateView, + UpdateView, + DeleteView, + TemplateView, +) +from django.shortcuts import get_object_or_404, redirect +from django.http import HttpResponseBadRequest, Http404 +from .models import Prefix, Suffix, Identifier, Permission +from .forms import PrefixForm, SuffixForm, IdentifierForm, PermissionForm + + +def resolve_doi(request, prefix, suffix, identifier): + try: + prefix_obj = Prefix.objects.get(prefix=prefix) + + if prefix_obj.type == "remote": + return redirect( + f"{prefix_obj.remote_resolver}/{prefix}.{suffix}/{identifier}" + ) + + suffix_obj = get_object_or_404( + Suffix, prefix=prefix_obj, suffix=suffix, approved=True + ) + + if suffix_obj.type == "remote": + return redirect( + f"{suffix_obj.remote_resolver}/{prefix}.{suffix}/{identifier}" + ) + + identifier_obj = get_object_or_404( + Identifier, suffix=suffix_obj, identifier=identifier + ) + return redirect(identifier_obj.target_url) + + except Prefix.DoesNotExist: + return HttpResponseBadRequest("Invalid DOI prefix") + + +class OwnerMixin: + def get_queryset(self): + return super().get_queryset().filter(owner=self.request.user) + + +class CustomPermissionMixin: + permission_type = "write" + + def has_permission(self, obj, permission_type="write"): + if self.request.user.is_superuser or obj.owner == self.request.user: + return True + + kwargs = { + "user": self.request.user, + "permission_type": permission_type, + } + + if isinstance(obj, Prefix): + kwargs["prefix"] = obj + elif isinstance(obj, Suffix): + kwargs["suffix"] = obj + elif isinstance(obj, Identifier): + kwargs["identifier"] = obj + + return Permission.objects.filter(**kwargs).exists() + + def dispatch(self, request, *args, **kwargs): + obj = self.get_object() + if not self.has_permission(obj, self.permission_type): + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) + + +class PrefixListView(LoginRequiredMixin, ListView): + model = Prefix + template_name = "resolver/prefix_list.html" + + def get_queryset(self): + qs = super().get_queryset() + return ( + qs.filter( + permission__user=self.request.user, + permission__permission_type="read", + ).distinct() + | qs.filter(owner=self.request.user).distinct() + ) + + +class PrefixDetailView(LoginRequiredMixin, CustomPermissionMixin, DetailView): + model = Prefix + template_name = "resolver/prefix_detail.html" + permission_type = "read" + + +class PrefixCreateView(LoginRequiredMixin, CreateView): + model = Prefix + form_class = PrefixForm + template_name = "resolver/prefix_form.html" + success_url = reverse_lazy("prefix_list") + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_superuser: + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + form.instance.owner = self.request.user + return super().form_valid(form) + + +class PrefixUpdateView(LoginRequiredMixin, CustomPermissionMixin, UpdateView): + model = Prefix + form_class = PrefixForm + template_name = "resolver/prefix_form.html" + success_url = reverse_lazy("prefix_list") + + +class PrefixDeleteView(LoginRequiredMixin, CustomPermissionMixin, DeleteView): + model = Prefix + template_name = "resolver/prefix_confirm_delete.html" + success_url = reverse_lazy("prefix_list") + + +class SuffixListView(LoginRequiredMixin, ListView): + model = Suffix + template_name = "resolver/suffix_list.html" + + def get_queryset(self): + qs = super().get_queryset() + return ( + qs.filter( + permission__user=self.request.user, + permission__permission_type="read", + ).distinct() + | qs.filter(owner=self.request.user).distinct() + ) + + +class SuffixDetailView(LoginRequiredMixin, CustomPermissionMixin, DetailView): + model = Suffix + template_name = "resolver/suffix_detail.html" + permission_type = "read" + + +class SuffixCreateView(LoginRequiredMixin, CreateView): + model = Suffix + form_class = SuffixForm + template_name = "resolver/suffix_form.html" + success_url = reverse_lazy("suffix_list") + + def dispatch(self, request, *args, **kwargs): + if request.user.suffix_set.count() >= request.user.suffix_limit: + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + def form_valid(self, form): + form.instance.owner = self.request.user + + if ( + self.request.user.is_superuser + or form.instance.prefix.owner == self.request.user + ): + form.instance.approved = True + + return super().form_valid(form) + + +class SuffixUpdateView(LoginRequiredMixin, CustomPermissionMixin, UpdateView): + model = Suffix + form_class = SuffixForm + template_name = "resolver/suffix_form.html" + success_url = reverse_lazy("suffix_list") + + +class SuffixDeleteView(LoginRequiredMixin, CustomPermissionMixin, DeleteView): + model = Suffix + template_name = "resolver/suffix_confirm_delete.html" + success_url = reverse_lazy("suffix_list") + + +class IdentifierListView(LoginRequiredMixin, ListView): + model = Identifier + template_name = "resolver/identifier_list.html" + + def get_queryset(self): + qs = super().get_queryset() + return ( + qs.filter( + suffix__permission__user=self.request.user, + suffix__permission__permission_type="read", + ).distinct() + | qs.filter(suffix__owner=self.request.user).distinct() + ) + + +class IdentifierDetailView(LoginRequiredMixin, CustomPermissionMixin, DetailView): + model = Identifier + template_name = "resolver/identifier_detail.html" + permission_type = "read" + + +class IdentifierCreateView(LoginRequiredMixin, CreateView): + model = Identifier + form_class = IdentifierForm + template_name = "resolver/identifier_form.html" + success_url = reverse_lazy("identifier_list") + + +class IdentifierUpdateView(LoginRequiredMixin, CustomPermissionMixin, UpdateView): + model = Identifier + form_class = IdentifierForm + template_name = "resolver/identifier_form.html" + success_url = reverse_lazy("identifier_list") + + +class IdentifierDeleteView(LoginRequiredMixin, CustomPermissionMixin, DeleteView): + model = Identifier + template_name = "resolver/identifier_confirm_delete.html" + success_url = reverse_lazy("identifier_list") + + +class SuffixIdentifierListView(LoginRequiredMixin, ListView): + model = Identifier + template_name = "resolver/identifier_list.html" + + def get_queryset(self): + self.suffix = get_object_or_404(Suffix, pk=self.kwargs["suffix_pk"]) + + if ( + not self.request.user.is_superuser + and not self.request.user == self.suffix.owner + ): + if not Permission.objects.filter( + user=self.request.user, suffix=self.suffix, permission_type="read" + ).exists(): + raise Http404("You do not have permission to view this object") + + return Identifier.objects.filter(suffix=self.suffix) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["suffix"] = self.suffix + return context + + +class PermissionCreateView(LoginRequiredMixin, CreateView): + model = Permission + form_class = PermissionForm + template_name = "resolver/custom_permission_form.html" + success_url = reverse_lazy("prefix_list") + + def form_valid(self, form): + if form.instance.prefix and form.instance.prefix.owner != self.request.user: + return self.handle_no_permission() + if form.instance.suffix and form.instance.suffix.owner != self.request.user: + return self.handle_no_permission() + return super().form_valid(form) + + +class HomeView(TemplateView): + template_name = "resolver/home.html" diff --git a/freedoi/settings.ini.template b/freedoi/settings.ini.template new file mode 100644 index 0000000..5bda867 --- /dev/null +++ b/freedoi/settings.ini.template @@ -0,0 +1,3 @@ +[freedoi] +debug = 0 +host = freedoi.local \ No newline at end of file diff --git a/freedoi/settings.py b/freedoi/settings.py new file mode 100644 index 0000000..68acd40 --- /dev/null +++ b/freedoi/settings.py @@ -0,0 +1,121 @@ +from pathlib import Path + +from autosecretkey import AutoSecretKey + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent + +ask = AutoSecretKey("settings.ini", template=BASE_DIR / "settings.ini.template") + +SECRET_KEY = ask.secret_key +CONFIG = ask.config +DEBUG = CONFIG.getboolean("FreeDOI", "debug", fallback=False) + +ALLOWED_HOSTS = CONFIG.get("FreeDOI", "host", fallback="*").split(",") + +if "*" in ALLOWED_HOSTS: + ALLOWED_HOSTS = ["*"] + +CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS if host != "*"] + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "freedoi.resolver", + "freedoi.accounts", + "django_otp", + "django_otp.plugins.otp_totp", + "two_factor", +] + +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", + "django_otp.middleware.OTPMiddleware", +] + +ROOT_URLCONF = "freedoi.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 = "freedoi.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = "accounts.CustomUser" + +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", + }, +] + + +# 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/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/freedoi/urls.py b/freedoi/urls.py new file mode 100644 index 0000000..c790213 --- /dev/null +++ b/freedoi/urls.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from django.urls import path, include + +from two_factor.urls import urlpatterns as tf_urls + +urlpatterns = [ + path("admin/", admin.site.urls), + path('accounts/', include('freedoi.accounts.urls')), + path("", include("freedoi.resolver.urls")), + path('', include(tf_urls)), +] diff --git a/freedoi/wsgi.py b/freedoi/wsgi.py new file mode 100644 index 0000000..99c0495 --- /dev/null +++ b/freedoi/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for freedoi 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', 'freedoi.settings') + +application = get_wsgi_application() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6b9fd06 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[tool.poetry] +name = "freedoi" +version = "0.1.0" +description = "" +authors = ["Private.coffee Team "] +license = "MIT" +readme = "README.md" +homepage = "https://freedoi.org" +repository = "https://git.private.coffee/PrivateCoffee/freedoi" + +[tool.poetry.dependencies] +python = "^3.10" +django = "^5.0" +djangorestframework = "*" +setuptools = "*" +pillow = "*" +pygments = "*" +coreapi = "*" +pyyaml = "*" +django-autosecretkey = "*" +django-celery-results = "*" +django-celery-beat = "*" +drf-spectacular = {extras = ["sidecar"], version = "*"} +argon2-cffi = "*" +django-csp = "*" +django-rest-polymorphic = "*" +django-crispy-forms = "*" +crispy-bootstrap5 = "*" +django-two-factor-auth = "*" +phonenumbers = "*" + +[tool.poetry.group.mysql.dependencies] +mysqlclient = "*" + +[tool.poetry.group.postgres.dependencies] +psycopg2 = "*" + +[tool.poetry.dev-dependencies] +pytest = "^5.2" + +[tool.poetry.scripts] +freedoi-manage = "freedoi.manage:main" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api"