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:
Kumi 2024-06-22 18:32:37 +02:00
commit dbf7cde183
Signed by: kumi
GPG key ID: ECBCC9082395383F
54 changed files with 1571 additions and 0 deletions

9
.gitignore vendored Normal file
View file

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

17
.vscode/launch.json vendored Normal file
View 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
View 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
View 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
View file

View file

View file

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

6
freedoi/accounts/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "freedoi.accounts"

View file

@ -0,0 +1,4 @@
from django import forms
class EmailForm(forms.Form):
email = forms.EmailField()

View 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,
},
),
]

View file

@ -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),
),
]

View file

View 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

View 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 %}

View 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 %}

View 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 %}

View file

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

20
freedoi/accounts/urls.py Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', '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()

View file

36
freedoi/resolver/admin.py Normal file
View 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
View 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
View 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"]

View 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")},
},
),
]

View 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")
},
},
),
]

View file

@ -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,
),
]

View file

@ -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),
),
]

View file

@ -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"]},
),
]

View file

111
freedoi/resolver/models.py Normal file
View 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 ''}"

View 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>

View 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 %}

View file

@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View file

@ -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 %}

View 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 %}

View 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 %}

View 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 }} &dash; {{ 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 %}

View file

@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View file

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

60
freedoi/resolver/urls.py Normal file
View 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
View 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"

View file

@ -0,0 +1,3 @@
[freedoi]
debug = 0
host = freedoi.local

121
freedoi/settings.py Normal file
View 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
View 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
View 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
View 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"