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.
This commit is contained in:
commit
dbf7cde183
54 changed files with 1571 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/*
|
||||
media/
|
||||
.venv/
|
||||
venv/
|
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
19
LICENSE
Normal file
19
LICENSE
Normal file
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2024 Private.coffee Team <support@private.coffee>
|
||||
|
||||
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.
|
29
README.md
Normal file
29
README.md
Normal file
|
@ -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).
|
0
freedoi/__init__.py
Normal file
0
freedoi/__init__.py
Normal file
0
freedoi/accounts/__init__.py
Normal file
0
freedoi/accounts/__init__.py
Normal file
3
freedoi/accounts/admin.py
Normal file
3
freedoi/accounts/admin.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
6
freedoi/accounts/apps.py
Normal file
6
freedoi/accounts/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "freedoi.accounts"
|
4
freedoi/accounts/forms.py
Normal file
4
freedoi/accounts/forms.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from django import forms
|
||||
|
||||
class EmailForm(forms.Form):
|
||||
email = forms.EmailField()
|
74
freedoi/accounts/migrations/0001_initial.py
Normal file
74
freedoi/accounts/migrations/0001_initial.py
Normal file
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
0
freedoi/accounts/migrations/__init__.py
Normal file
0
freedoi/accounts/migrations/__init__.py
Normal file
39
freedoi/accounts/models.py
Normal file
39
freedoi/accounts/models.py
Normal file
|
@ -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
|
9
freedoi/accounts/templates/accounts/email_sent.html
Normal file
9
freedoi/accounts/templates/accounts/email_sent.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends "base_generic.html" %} {% block title %}Email Sent{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Email Sent</h2>
|
||||
<p>
|
||||
A login link has been sent to your email address. Please check your inbox.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
10
freedoi/accounts/templates/accounts/login.html
Normal file
10
freedoi/accounts/templates/accounts/login.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends "base_generic.html" %} {% block title %}Login{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Login</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %} {{ form.as_p }}
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
10
freedoi/accounts/templates/accounts/send_login_email.html
Normal file
10
freedoi/accounts/templates/accounts/send_login_email.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends "base_generic.html" %} {% block title %}Email Login{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Email Login</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %} {{ form.as_p }}
|
||||
<button type="submit" class="btn btn-primary">Send Login Link</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
3
freedoi/accounts/tests.py
Normal file
3
freedoi/accounts/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
20
freedoi/accounts/urls.py
Normal file
20
freedoi/accounts/urls.py
Normal file
|
@ -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/<uidb64>/<token>/", 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",
|
||||
),
|
||||
]
|
46
freedoi/accounts/views.py
Normal file
46
freedoi/accounts/views.py
Normal file
|
@ -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')
|
16
freedoi/asgi.py
Normal file
16
freedoi/asgi.py
Normal file
|
@ -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()
|
22
freedoi/manage.py
Executable file
22
freedoi/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', '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()
|
0
freedoi/resolver/__init__.py
Normal file
0
freedoi/resolver/__init__.py
Normal file
36
freedoi/resolver/admin.py
Normal file
36
freedoi/resolver/admin.py
Normal file
|
@ -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")
|
6
freedoi/resolver/apps.py
Normal file
6
freedoi/resolver/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ResolverConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'freedoi.resolver'
|
53
freedoi/resolver/forms.py
Normal file
53
freedoi/resolver/forms.py
Normal file
|
@ -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"]
|
96
freedoi/resolver/migrations/0001_initial.py
Normal file
96
freedoi/resolver/migrations/0001_initial.py
Normal file
|
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
75
freedoi/resolver/migrations/0002_permission.py
Normal file
75
freedoi/resolver/migrations/0002_permission.py
Normal file
|
@ -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")
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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"]},
|
||||
),
|
||||
]
|
0
freedoi/resolver/migrations/__init__.py
Normal file
0
freedoi/resolver/migrations/__init__.py
Normal file
111
freedoi/resolver/models.py
Normal file
111
freedoi/resolver/models.py
Normal file
|
@ -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 ''}"
|
67
freedoi/resolver/templates/base_generic.html
Normal file
67
freedoi/resolver/templates/base_generic.html
Normal file
|
@ -0,0 +1,67 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}FreeDOI{% endblock %}</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
|
||||
/>
|
||||
<style>
|
||||
body {
|
||||
padding-top: 56px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'home' %}">FreeDOI</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#navbarResponsive"
|
||||
aria-controls="navbarResponsive"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarResponsive">
|
||||
<ul class="navbar-nav ml-auto">
|
||||
{% if user.is_authenticated %}
|
||||
{% if user.is_superuser or user.prefixes.exists %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'prefix_list' %}">Prefixes</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'suffix_list' %}">Suffixes</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'identifier_list' %}"
|
||||
>Identifiers</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'send_login_email' %}">Login</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">{% block content %} {% endblock %}</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.4/dist/umd/popper.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
21
freedoi/resolver/templates/resolver/home.html
Normal file
21
freedoi/resolver/templates/resolver/home.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends "base_generic.html" %} {% block title %}Home{% endblock %}
|
||||
{% block content %}
|
||||
<div class="jumbotron">
|
||||
<h1 class="display-4">Welcome to FreeDOI</h1>
|
||||
<p class="lead">This is a simple system for managing and resolving DOIs.</p>
|
||||
<hr class="my-4" />
|
||||
<p>
|
||||
Use the navigation links above to manage prefixes, suffixes, and
|
||||
identifiers.
|
||||
</p>
|
||||
{% if user.is_authenticated %}
|
||||
<a class="btn btn-primary btn-lg" href="{% url 'suffix_list' %}" role="button"
|
||||
>Manage Suffixes</a
|
||||
>
|
||||
{% else %}
|
||||
<a class="btn btn-primary btn-lg" href="{% url 'login' %}" role="button"
|
||||
>Login</a
|
||||
>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,9 @@
|
|||
{% extends "base_generic.html" %} {% block content %}
|
||||
<h1>Delete Identifier</h1>
|
||||
<p>Are you sure you want to delete "{{ object.identifier }}"?</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="submit" value="Delete" />
|
||||
</form>
|
||||
<a href="{% url 'identifier_list' %}">Back to list</a>
|
||||
{% endblock %}
|
11
freedoi/resolver/templates/resolver/identifier_detail.html
Normal file
11
freedoi/resolver/templates/resolver/identifier_detail.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends "base_generic.html" %} {% block content %}
|
||||
<h1>{{ object.identifier }}</h1>
|
||||
<p>Suffix: {{ object.suffix }}</p>
|
||||
<p>Target URL: {{ object.target_url }}</p>
|
||||
<a href="{% url 'identifier_update' object.pk %}">Edit</a>
|
||||
<form method="post" action="{% url 'identifier_delete' object.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="submit" value="Delete" />
|
||||
</form>
|
||||
<a href="{% url 'identifier_list' %}">Back to list</a>
|
||||
{% endblock %}
|
8
freedoi/resolver/templates/resolver/identifier_form.html
Normal file
8
freedoi/resolver/templates/resolver/identifier_form.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base_generic.html" %} {% block content %}
|
||||
<h1>{% if form.instance.pk %}Edit{% else %}Create{% endif %} Identifier</h1>
|
||||
<form method="post">
|
||||
{% csrf_token %} {{ form.as_p }}
|
||||
<input type="submit" value="Save" />
|
||||
</form>
|
||||
<a href="{% url 'identifier_list' %}">Back to list</a>
|
||||
{% endblock %}
|
13
freedoi/resolver/templates/resolver/identifier_list.html
Normal file
13
freedoi/resolver/templates/resolver/identifier_list.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends "base_generic.html" %} {% block content %}
|
||||
<h1>Identifiers</h1>
|
||||
<ul>
|
||||
{% for identifier in object_list %}
|
||||
<li>
|
||||
<a href="{% url 'identifier_detail' identifier.pk %}"
|
||||
>{{ identifier }}</a
|
||||
>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="{% url 'identifier_create' %}">Create new identifier</a>
|
||||
{% endblock %}
|
|
@ -0,0 +1,10 @@
|
|||
{% extends "base_generic.html" %} {% block title %}Delete Prefix{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Delete Prefix</h1>
|
||||
<p>Are you sure you want to delete "{{ object.name }}"?</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input class="btn btn-danger" type="submit" value="Delete" />
|
||||
</form>
|
||||
<a class="btn btn-link" href="{% url 'prefix_list' %}">Back to list</a>
|
||||
{% endblock %}
|
17
freedoi/resolver/templates/resolver/prefix_detail.html
Normal file
17
freedoi/resolver/templates/resolver/prefix_detail.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% extends "base_generic.html" %} {% block title %}Prefix Detail{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ object.name }}</h1>
|
||||
<p><strong>Prefix:</strong> {{ object.prefix }}</p>
|
||||
<p><strong>Type:</strong> {{ object.get_type_display }}</p>
|
||||
<p><strong>Remote Resolver:</strong> {{ object.remote_resolver }}</p>
|
||||
<a class="btn btn-secondary" href="{% url 'prefix_update' object.pk %}">Edit</a>
|
||||
<form
|
||||
method="post"
|
||||
action="{% url 'prefix_delete' object.pk %}"
|
||||
style="display: inline"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input class="btn btn-danger" type="submit" value="Delete" />
|
||||
</form>
|
||||
<a class="btn btn-link" href="{% url 'prefix_list' %}">Back to list</a>
|
||||
{% endblock %}
|
10
freedoi/resolver/templates/resolver/prefix_form.html
Normal file
10
freedoi/resolver/templates/resolver/prefix_form.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends "base_generic.html" %} {% block title %}
|
||||
{% if form.instance.pk %}Edit{% else %}Create{% endif %} Prefix{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% if form.instance.pk %}Edit{% else %}Create{% endif %} Prefix</h1>
|
||||
<form method="post">
|
||||
{% csrf_token %} {{ form.as_p }}
|
||||
<input class="btn btn-primary" type="submit" value="Save" />
|
||||
</form>
|
||||
<a class="btn btn-link" href="{% url 'prefix_list' %}">Back to list</a>
|
||||
{% endblock %}
|
16
freedoi/resolver/templates/resolver/prefix_list.html
Normal file
16
freedoi/resolver/templates/resolver/prefix_list.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends "base_generic.html" %} {% block title %}Prefixes{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Prefixes</h1>
|
||||
<ul class="list-group">
|
||||
{% for prefix in object_list %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'prefix_detail' prefix.pk %}">{{ prefix.prefix }} ‐ {{ prefix.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if user.is_superuser %}
|
||||
<a class="btn btn-primary mt-3" href="{% url 'prefix_create' %}"
|
||||
>Create new prefix</a
|
||||
>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,9 @@
|
|||
{% extends "base_generic.html" %} {% block content %}
|
||||
<h1>Delete Suffix</h1>
|
||||
<p>Are you sure you want to delete "{{ object.name }}"?</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="submit" value="Delete" />
|
||||
</form>
|
||||
<a href="{% url 'suffix_list' %}">Back to list</a>
|
||||
{% endblock %}
|
23
freedoi/resolver/templates/resolver/suffix_detail.html
Normal file
23
freedoi/resolver/templates/resolver/suffix_detail.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% extends "base_generic.html" %} {% block title %}Suffix Detail{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ object.name }}</h1>
|
||||
<p><strong>Suffix:</strong> {{ object.suffix }}</p>
|
||||
<p><strong>Prefix:</strong> {{ object.prefix }}</p>
|
||||
<p><strong>Type:</strong> {{ object.get_type_display }}</p>
|
||||
<p><strong>Remote Resolver:</strong> {{ object.remote_resolver }}</p>
|
||||
<a class="btn btn-secondary" href="{% url 'suffix_update' object.pk %}">Edit</a>
|
||||
<form
|
||||
method="post"
|
||||
action="{% url 'suffix_delete' object.pk %}"
|
||||
style="display: inline"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input class="btn btn-danger" type="submit" value="Delete" />
|
||||
</form>
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
href="{% url 'identifier_list' suffix_pk=object.pk %}"
|
||||
>View Identifiers</a
|
||||
>
|
||||
<a class="btn btn-link" href="{% url 'suffix_list' %}">Back to list</a>
|
||||
{% endblock %}
|
8
freedoi/resolver/templates/resolver/suffix_form.html
Normal file
8
freedoi/resolver/templates/resolver/suffix_form.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base_generic.html" %} {% block content %}
|
||||
<h1>{% if form.instance.pk %}Edit{% else %}Create{% endif %} Suffix</h1>
|
||||
<form method="post">
|
||||
{% csrf_token %} {{ form.as_p }}
|
||||
<input type="submit" value="Save" />
|
||||
</form>
|
||||
<a href="{% url 'suffix_list' %}">Back to list</a>
|
||||
{% endblock %}
|
9
freedoi/resolver/templates/resolver/suffix_list.html
Normal file
9
freedoi/resolver/templates/resolver/suffix_list.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends "base_generic.html" %} {% block content %}
|
||||
<h1>Suffixes</h1>
|
||||
<ul>
|
||||
{% for suffix in object_list %}
|
||||
<li><a href="{% url 'suffix_detail' suffix.pk %}">{{ suffix.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="{% url 'suffix_create' %}">Create new suffix</a>
|
||||
{% endblock %}
|
3
freedoi/resolver/tests.py
Normal file
3
freedoi/resolver/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
60
freedoi/resolver/urls.py
Normal file
60
freedoi/resolver/urls.py
Normal file
|
@ -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("<prefix>.<suffix>/<path:identifier>/", resolve_doi, name="resolve_doi"),
|
||||
path("prefixes/", PrefixListView.as_view(), name="prefix_list"),
|
||||
path("prefixes/<int:pk>/", PrefixDetailView.as_view(), name="prefix_detail"),
|
||||
path("prefixes/create/", PrefixCreateView.as_view(), name="prefix_create"),
|
||||
path("prefixes/<int:pk>/update/", PrefixUpdateView.as_view(), name="prefix_update"),
|
||||
path("prefixes/<int:pk>/delete/", PrefixDeleteView.as_view(), name="prefix_delete"),
|
||||
path("suffixes/", SuffixListView.as_view(), name="suffix_list"),
|
||||
path("suffixes/<int:pk>/", SuffixDetailView.as_view(), name="suffix_detail"),
|
||||
path("suffixes/create/", SuffixCreateView.as_view(), name="suffix_create"),
|
||||
path("suffixes/<int:pk>/update/", SuffixUpdateView.as_view(), name="suffix_update"),
|
||||
path("suffixes/<int:pk>/delete/", SuffixDeleteView.as_view(), name="suffix_delete"),
|
||||
path("identifiers/", IdentifierListView.as_view(), name="identifier_list"),
|
||||
path(
|
||||
"identifiers/<int:pk>/",
|
||||
IdentifierDetailView.as_view(),
|
||||
name="identifier_detail",
|
||||
),
|
||||
path(
|
||||
"identifiers/create/", IdentifierCreateView.as_view(), name="identifier_create"
|
||||
),
|
||||
path(
|
||||
"identifiers/<int:pk>/update/",
|
||||
IdentifierUpdateView.as_view(),
|
||||
name="identifier_update",
|
||||
),
|
||||
path(
|
||||
"identifiers/<int:pk>/delete/",
|
||||
IdentifierDeleteView.as_view(),
|
||||
name="identifier_delete",
|
||||
),
|
||||
path('suffixes/<int:suffix_pk>/identifiers/', SuffixIdentifierListView.as_view(), name='identifier_list'),
|
||||
path('suffixes/<int:suffix_pk>/identifiers/<int:pk>/', IdentifierDetailView.as_view(), name='identifier_detail'),
|
||||
path('suffixes/<int:suffix_pk>/identifiers/create/', IdentifierCreateView.as_view(), name='identifier_create'),
|
||||
path('suffixes/<int:suffix_pk>/identifiers/<int:pk>/update/', IdentifierUpdateView.as_view(), name='identifier_update'),
|
||||
path('suffixes/<int:suffix_pk>/identifiers/<int:pk>/delete/', IdentifierDeleteView.as_view(), name='identifier_delete'),
|
||||
]
|
269
freedoi/resolver/views.py
Normal file
269
freedoi/resolver/views.py
Normal file
|
@ -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"
|
3
freedoi/settings.ini.template
Normal file
3
freedoi/settings.ini.template
Normal file
|
@ -0,0 +1,3 @@
|
|||
[freedoi]
|
||||
debug = 0
|
||||
host = freedoi.local
|
121
freedoi/settings.py
Normal file
121
freedoi/settings.py
Normal file
|
@ -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"
|
11
freedoi/urls.py
Normal file
11
freedoi/urls.py
Normal file
|
@ -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)),
|
||||
]
|
16
freedoi/wsgi.py
Normal file
16
freedoi/wsgi.py
Normal file
|
@ -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()
|
46
pyproject.toml
Normal file
46
pyproject.toml
Normal file
|
@ -0,0 +1,46 @@
|
|||
[tool.poetry]
|
||||
name = "freedoi"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Private.coffee Team <support@private.coffee>"]
|
||||
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"
|
Loading…
Reference in a new issue