mirror of
https://github.com/Steffo99/sophon.git
synced 2024-12-22 14:54:22 +00:00
Merge permissions-v3
(#64)
This commit is contained in:
parent
5cff70db67
commit
b9d3fe94fe
33 changed files with 817 additions and 1477 deletions
|
@ -20,6 +20,7 @@
|
||||||
<inspection_tool class="PyInconsistentIndentationInspection" enabled="true" level="ERROR" enabled_by_default="true" />
|
<inspection_tool class="PyInconsistentIndentationInspection" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||||
<inspection_tool class="PyInterpreterInspection" enabled="true" level="ERROR" enabled_by_default="true" />
|
<inspection_tool class="PyInterpreterInspection" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||||
<inspection_tool class="PyMandatoryEncodingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
<inspection_tool class="PyMandatoryEncodingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="PyMethodParametersInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
<inspection_tool class="PyMissingTypeHintsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
<inspection_tool class="PyMissingTypeHintsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="PyNestedDecoratorsInspection" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="PyNestedDecoratorsInspection" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="PyNoneFunctionAssignmentInspection" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="PyNoneFunctionAssignmentInspection" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
|
|
@ -6,4 +6,7 @@
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_15" project-jdk-name="Poetry (backend)" project-jdk-type="Python SDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_15" project-jdk-name="Poetry (backend)" project-jdk-type="Python SDK">
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
</component>
|
</component>
|
||||||
|
<component name="PythonCompatibilityInspectionAdvertiser">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
</project>
|
</project>
|
44
backend/poetry.lock
generated
44
backend/poetry.lock
generated
|
@ -25,6 +25,17 @@ category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deprecation"
|
||||||
|
version = "2.1.0"
|
||||||
|
description = "A library to handle automated deprecations"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
packaging = "*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "3.2"
|
version = "3.2"
|
||||||
|
@ -125,6 +136,17 @@ category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "21.0"
|
||||||
|
description = "Core utilities for Python packages"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
pyparsing = ">=2.0.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pandas"
|
name = "pandas"
|
||||||
version = "1.2.4"
|
version = "1.2.4"
|
||||||
|
@ -181,6 +203,14 @@ dotenv = ["python-dotenv (>=0.10.4)"]
|
||||||
email = ["email-validator (>=1.0.3)"]
|
email = ["email-validator (>=1.0.3)"]
|
||||||
typing_extensions = ["typing-extensions (>=3.7.2)"]
|
typing_extensions = ["typing-extensions (>=3.7.2)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyparsing"
|
||||||
|
version = "2.4.7"
|
||||||
|
description = "Python parsing module"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.8.1"
|
version = "2.8.1"
|
||||||
|
@ -258,7 +288,7 @@ brotli = ["brotlipy (>=0.6.0)"]
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.9"
|
python-versions = "^3.9"
|
||||||
content-hash = "5414aa8aa7d0a6610e8c65a8686696ae7d40cdc931c9c2e30b61d5e613a17ef8"
|
content-hash = "d14b706e9bdac9f5a747a783482242a12d2881cc08a6a53b46f3985562b20ed8"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
asgiref = [
|
asgiref = [
|
||||||
|
@ -273,6 +303,10 @@ chardet = [
|
||||||
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
|
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
|
||||||
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
|
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
|
||||||
]
|
]
|
||||||
|
deprecation = [
|
||||||
|
{file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"},
|
||||||
|
{file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"},
|
||||||
|
]
|
||||||
django = [
|
django = [
|
||||||
{file = "Django-3.2-py3-none-any.whl", hash = "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927"},
|
{file = "Django-3.2-py3-none-any.whl", hash = "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927"},
|
||||||
{file = "Django-3.2.tar.gz", hash = "sha256:21f0f9643722675976004eb683c55d33c05486f94506672df3d6a141546f389d"},
|
{file = "Django-3.2.tar.gz", hash = "sha256:21f0f9643722675976004eb683c55d33c05486f94506672df3d6a141546f389d"},
|
||||||
|
@ -375,6 +409,10 @@ numpy = [
|
||||||
{file = "numpy-1.20.2-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:97ce8b8ace7d3b9288d88177e66ee75480fb79b9cf745e91ecfe65d91a856042"},
|
{file = "numpy-1.20.2-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:97ce8b8ace7d3b9288d88177e66ee75480fb79b9cf745e91ecfe65d91a856042"},
|
||||||
{file = "numpy-1.20.2.zip", hash = "sha256:878922bf5ad7550aa044aa9301d417e2d3ae50f0f577de92051d739ac6096cee"},
|
{file = "numpy-1.20.2.zip", hash = "sha256:878922bf5ad7550aa044aa9301d417e2d3ae50f0f577de92051d739ac6096cee"},
|
||||||
]
|
]
|
||||||
|
packaging = [
|
||||||
|
{file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
|
||||||
|
{file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
|
||||||
|
]
|
||||||
pandas = [
|
pandas = [
|
||||||
{file = "pandas-1.2.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c601c6fdebc729df4438ec1f62275d6136a0dd14d332fc0e8ce3f7d2aadb4dd6"},
|
{file = "pandas-1.2.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c601c6fdebc729df4438ec1f62275d6136a0dd14d332fc0e8ce3f7d2aadb4dd6"},
|
||||||
{file = "pandas-1.2.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8d4c74177c26aadcfb4fd1de6c1c43c2bf822b3e0fc7a9b409eeaf84b3e92aaa"},
|
{file = "pandas-1.2.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8d4c74177c26aadcfb4fd1de6c1c43c2bf822b3e0fc7a9b409eeaf84b3e92aaa"},
|
||||||
|
@ -438,6 +476,10 @@ pydantic = [
|
||||||
{file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"},
|
{file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"},
|
||||||
{file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"},
|
{file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"},
|
||||||
]
|
]
|
||||||
|
pyparsing = [
|
||||||
|
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
|
||||||
|
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
||||||
|
]
|
||||||
python-dateutil = [
|
python-dateutil = [
|
||||||
{file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
|
{file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
|
||||||
{file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
|
{file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
|
||||||
|
|
|
@ -15,6 +15,7 @@ pandaSDMX = "^1.4.2"
|
||||||
pydantic = "~1.7.3"
|
pydantic = "~1.7.3"
|
||||||
django-pam = "^2.0.0"
|
django-pam = "^2.0.0"
|
||||||
django-colorfield = "^0.4.2"
|
django-colorfield = "^0.4.2"
|
||||||
|
deprecation = "^2.1.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class CoreAdmin(admin.ModelAdmin):
|
class SophonAdmin(admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
:class:`django.contrib.admin.ModelAdmin` class from which all other admin classes inherit.
|
Base :class:`django.contrib.admin.ModelAdmin` class from which all other admin classes inherit.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.ResearchGroup)
|
@admin.register(models.ResearchGroup)
|
||||||
class ResearchGroupAdmin(CoreAdmin):
|
class ResearchGroupAdmin(SophonAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
"slug",
|
"slug",
|
||||||
"name",
|
"name",
|
||||||
|
@ -20,137 +20,3 @@ class ResearchGroupAdmin(CoreAdmin):
|
||||||
ordering = (
|
ordering = (
|
||||||
"slug",
|
"slug",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.ResearchTag)
|
|
||||||
class ResearchTagAdmin(CoreAdmin):
|
|
||||||
list_display = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"color",
|
|
||||||
"owner",
|
|
||||||
)
|
|
||||||
|
|
||||||
ordering = (
|
|
||||||
"slug",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.ResearchProject)
|
|
||||||
class ResearchProjectAdmin(CoreAdmin):
|
|
||||||
list_display = (
|
|
||||||
"group",
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"visibility",
|
|
||||||
)
|
|
||||||
|
|
||||||
ordering = (
|
|
||||||
"slug",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description="Sync DataFlows")
|
|
||||||
def sync_flows_admin(modeladmin, request, queryset):
|
|
||||||
for datasource in queryset:
|
|
||||||
datasource: models.DataSource
|
|
||||||
try:
|
|
||||||
datasource.sync_flows()
|
|
||||||
except NotImplementedError:
|
|
||||||
modeladmin.message_user(
|
|
||||||
request,
|
|
||||||
f"Skipped {datasource}: Syncing DataFlows is not supported on this DataSource.",
|
|
||||||
level=messages.ERROR
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
modeladmin.message_user(
|
|
||||||
request,
|
|
||||||
f"Skipped {datasource}: {exc}",
|
|
||||||
level=messages.ERROR
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
modeladmin.log_change(request, datasource, "Sync DataFlows")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.DataSource)
|
|
||||||
class DataSourceAdmin(CoreAdmin):
|
|
||||||
list_display = (
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"data_content_type",
|
|
||||||
"last_sync",
|
|
||||||
)
|
|
||||||
|
|
||||||
ordering = (
|
|
||||||
"last_sync",
|
|
||||||
)
|
|
||||||
|
|
||||||
actions = (
|
|
||||||
sync_flows_admin,
|
|
||||||
)
|
|
||||||
|
|
||||||
fieldsets = (
|
|
||||||
(
|
|
||||||
None, {
|
|
||||||
"fields": (
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"URLs", {
|
|
||||||
"fields": (
|
|
||||||
"url",
|
|
||||||
"documentation",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"API configuration", {
|
|
||||||
"fields": (
|
|
||||||
"data_content_type",
|
|
||||||
"headers",
|
|
||||||
"resources",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Features supported", {
|
|
||||||
"fields": (
|
|
||||||
"supports_agencyscheme",
|
|
||||||
"supports_categoryscheme",
|
|
||||||
"supports_codelist",
|
|
||||||
"supports_conceptscheme",
|
|
||||||
"supports_data",
|
|
||||||
"supports_dataflow",
|
|
||||||
"supports_datastructure",
|
|
||||||
"supports_provisionagreement",
|
|
||||||
"supports_preview",
|
|
||||||
"supports_structurespecific_data",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Syncronization", {
|
|
||||||
"fields": (
|
|
||||||
"last_sync",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.DataFlow)
|
|
||||||
class DataFlowAdmin(CoreAdmin):
|
|
||||||
list_display = (
|
|
||||||
"datasource",
|
|
||||||
"sdmx_id",
|
|
||||||
"description",
|
|
||||||
)
|
|
||||||
|
|
||||||
ordering = (
|
|
||||||
"datasource",
|
|
||||||
"sdmx_id",
|
|
||||||
)
|
|
||||||
|
|
22
backend/sophon/core/enums.py
Normal file
22
backend/sophon/core/enums.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class SophonGroupAccess(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
The level of access an user has in a group.
|
||||||
|
|
||||||
|
From the highest to the lowest:
|
||||||
|
- Instance superuser
|
||||||
|
- Group owner
|
||||||
|
- Group member
|
||||||
|
- Instance user
|
||||||
|
- Anonymous user
|
||||||
|
|
||||||
|
Since access levels are instance of an :class:`~enum.IntEnum`, they can be compared with ``==``, ``!=``, ``>``, ``<``, ``>=`` and ``<=``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUPERUSER = 200
|
||||||
|
OWNER = 100
|
||||||
|
MEMBER = 50
|
||||||
|
REGISTERED = 10
|
||||||
|
NONE = 0
|
|
@ -1,123 +1,32 @@
|
||||||
# Generated by Django 3.2 on 2021-04-08 14:36
|
# Generated by Django 3.2 on 2021-08-10 21:20
|
||||||
|
|
||||||
import importlib.resources
|
from django.conf import settings
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
from .. import models as core_models
|
|
||||||
|
|
||||||
|
|
||||||
def create_builtin_sources(apps, schema_editor):
|
|
||||||
"""
|
|
||||||
Create in the database the sources that are already built-in in :mod:`pandasdmx`.
|
|
||||||
This function is called when performing the migration: see
|
|
||||||
`this page <https://docs.djangoproject.com/en/3.1/topics/migrations/#data-migrations>`_ for details on how this
|
|
||||||
works!
|
|
||||||
"""
|
|
||||||
file = importlib.resources.open_text("sophon.core.migrations", "0001_sources.json")
|
|
||||||
core_models.DataSource.create_from_sources_json(file=file)
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='DataFlow',
|
name='ResearchGroup',
|
||||||
fields=[
|
fields=[
|
||||||
('surrogate_id', models.IntegerField(help_text='Internal id used by Django to identify this DataFlow.',
|
('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the group in the Sophon instance.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')),
|
||||||
primary_key=True, serialize=False, verbose_name='Surrogate id')),
|
('name', models.CharField(help_text='The displayed name of the group.', max_length=512, verbose_name='Name')),
|
||||||
('id',
|
('description', models.TextField(blank=True, help_text='A brief description of what the group is about.', verbose_name='Description')),
|
||||||
models.CharField(help_text='Internal string used in SDMX communication to identify the DataFlow.',
|
('access', models.CharField(choices=[('MANUAL', '⛔️ Collaborators must be added manually'), ('OPEN', '❇️ Users can join the group freely')], default='MANUAL', help_text='A setting specifying how users can join the group.', max_length=16, verbose_name='Access')),
|
||||||
max_length=64, verbose_name='SDMX id')),
|
('members', models.ManyToManyField(blank=True, help_text='The users who belong to this group.', related_name='is_a_member_of', to=settings.AUTH_USER_MODEL)),
|
||||||
('description', models.TextField(blank=True, help_text='Natural language description of the DataFlow.',
|
('owner', models.ForeignKey(help_text='The user who created the group, who is automatically a member.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
verbose_name='Description')),
|
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Research Group',
|
||||||
|
'verbose_name_plural': 'Research Groups',
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name='DataSource',
|
|
||||||
fields=[
|
|
||||||
('id',
|
|
||||||
models.CharField(help_text='Internal id used by PandaSDMX to reference the source.', max_length=16,
|
|
||||||
primary_key=True, serialize=False, verbose_name='PandaSDMX id')),
|
|
||||||
('name', models.CharField(help_text='Full length name of the data source.', max_length=512,
|
|
||||||
verbose_name='Name')),
|
|
||||||
('description', models.TextField(blank=True, help_text='Long description of the data source.',
|
|
||||||
verbose_name='Description')),
|
|
||||||
('url', models.URLField(help_text='The base URL of the SDMX endpoint of the data source.',
|
|
||||||
verbose_name='API URL')),
|
|
||||||
('documentation', models.URLField(help_text='Documentation URL of the data source.', null=True,
|
|
||||||
verbose_name='Documentation URL')),
|
|
||||||
('data_content_type', models.CharField(choices=[('JSON', 'JSON'), ('XML', 'XML')], default='XML',
|
|
||||||
help_text='The format in which the API returns its data.',
|
|
||||||
max_length=16, verbose_name='API type')),
|
|
||||||
('headers',
|
|
||||||
models.JSONField(default=dict, help_text='HTTP headers to attach to every request, as a JSON object.',
|
|
||||||
verbose_name='HTTP Headers')),
|
|
||||||
('resources', models.JSONField(default=dict, help_text='Unknown and undocumented JSON object.',
|
|
||||||
verbose_name='Resources')),
|
|
||||||
('supports_agencyscheme', models.BooleanField(default=True,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.AgencyScheme">AgencyScheme </a> or not.',
|
|
||||||
verbose_name='Supports AgencyScheme')),
|
|
||||||
('supports_categoryscheme', models.BooleanField(default=True,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.CategoryScheme">CategoryScheme </a> or not.',
|
|
||||||
verbose_name='Supports CategoryScheme')),
|
|
||||||
('supports_codelist', models.BooleanField(default=True,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.CodeList">CodeList </a> or not.',
|
|
||||||
verbose_name='Supports CodeList')),
|
|
||||||
('supports_conceptscheme', models.BooleanField(default=True,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.ConceptScheme">ConceptScheme </a> or not.',
|
|
||||||
verbose_name='Supports ConceptScheme')),
|
|
||||||
('supports_data', models.BooleanField(default=True,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataSet">DataSet </a> or not.',
|
|
||||||
verbose_name='Supports DataSet')),
|
|
||||||
('supports_dataflow', models.BooleanField(default=True,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataflowDefinition">DataflowDefinition </a> or not.',
|
|
||||||
verbose_name='Supports DataflowDefinition')),
|
|
||||||
('supports_datastructure', models.BooleanField(default=True,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataStructureDefinition">CategoryScheme </a> or not.',
|
|
||||||
verbose_name='Supports DataStructureDefinition')),
|
|
||||||
('supports_provisionagreement', models.BooleanField(default=True,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.ProvisionAgreement">CategoryScheme </a> or not.',
|
|
||||||
verbose_name='Supports ProvisionAgreement')),
|
|
||||||
('supports_preview', models.BooleanField(default=False,
|
|
||||||
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.Request.preview_data">previews of data </a> or not.',
|
|
||||||
verbose_name='Supports previews')),
|
|
||||||
('supports_structurespecific_data', models.BooleanField(default=False,
|
|
||||||
help_text='Whether the data source returns <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.source.Source">structure-specific data messages </a> or not.',
|
|
||||||
verbose_name='Supports structure-specific data messages')),
|
|
||||||
('builtin', models.BooleanField(help_text='Whether the source is built-in in PandaSDMX or not.',
|
|
||||||
verbose_name='Builtin')),
|
|
||||||
('last_sync', models.DateTimeField(
|
|
||||||
help_text='The datetime at which the data flows of this source were last syncronized.', null=True,
|
|
||||||
verbose_name='Last updated')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Project',
|
|
||||||
fields=[
|
|
||||||
('slug',
|
|
||||||
models.SlugField(help_text='Unique alphanumeric string which identifies the project.', max_length=64,
|
|
||||||
primary_key=True, serialize=False, verbose_name='Slug')),
|
|
||||||
('name',
|
|
||||||
models.CharField(help_text='The display name of the project.', max_length=512, verbose_name='Name')),
|
|
||||||
('description', models.TextField(blank=True,
|
|
||||||
help_text='A brief description of the project, to be displayed inthe overview.',
|
|
||||||
verbose_name='Description')),
|
|
||||||
('flows', models.ManyToManyField(blank=True, help_text='The DataFlows used in this project.',
|
|
||||||
related_name='used_in', to='core.DataFlow')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dataflow',
|
|
||||||
name='datasource',
|
|
||||||
field=models.ForeignKey(help_text='The DataSource this object belongs to.',
|
|
||||||
on_delete=django.db.models.deletion.RESTRICT, to='core.datasource'),
|
|
||||||
),
|
|
||||||
migrations.RunPython(create_builtin_sources)
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,194 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "ABS",
|
|
||||||
"data_content_type": "JSON",
|
|
||||||
"url": "https://stat.data.abs.gov.au/sdmx-json",
|
|
||||||
"name": "Australian Bureau of Statistics",
|
|
||||||
"documentation": "https://www.abs.gov.au/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ECB",
|
|
||||||
"resources": {
|
|
||||||
"data": {
|
|
||||||
"headers": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"url": "https://sdw-wsrest.ecb.europa.eu/service",
|
|
||||||
"name": "European Central Bank",
|
|
||||||
"documentation": "https://www.ecb.europa.eu/stats/ecb_statistics/co-operation_and_standards/sdmx/html/index.en.html",
|
|
||||||
"supports": {
|
|
||||||
"preview": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ESTAT",
|
|
||||||
"documentation": "https://data.un.org/Host.aspx?Content=API",
|
|
||||||
"url": "https://ec.europa.eu/eurostat/SDMX/diss-web/rest",
|
|
||||||
"name": "Eurostat",
|
|
||||||
"supports": {
|
|
||||||
"agencyscheme": false,
|
|
||||||
"categoryscheme": false,
|
|
||||||
"codelist": false,
|
|
||||||
"conceptscheme": false,
|
|
||||||
"provisionagreement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ILO",
|
|
||||||
"name": "International Labor Organization",
|
|
||||||
"documentation": "https://www.ilo.org/ilostat/",
|
|
||||||
"url": "https://www.ilo.org/sdmx/rest",
|
|
||||||
"headers": {
|
|
||||||
"accept": "application/vnd.sdmx.structurespecificdata+xml;version=2.1"
|
|
||||||
},
|
|
||||||
"supports": {
|
|
||||||
"provisionagreement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "IMF",
|
|
||||||
"url": "https://sdmxcentral.imf.org/ws/public/sdmxapi/rest",
|
|
||||||
"name": "International Monetary Fund",
|
|
||||||
"supports": {
|
|
||||||
"provisionagreement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "INEGI",
|
|
||||||
"url": "https://sdmx.snieg.mx/service/rest",
|
|
||||||
"name": "Instituto Nacional de Estadística y Geografía (MX)",
|
|
||||||
"documentation": "https://sdmx.snieg.mx/infrastructure",
|
|
||||||
"supports": {
|
|
||||||
"agencyscheme": false,
|
|
||||||
"provisionagreement": false,
|
|
||||||
"structure-specific data": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "INSEE",
|
|
||||||
"name": "Institut national de la statistique et des études économiques (FR)",
|
|
||||||
"documentation": "https://www.bdm.insee.fr/bdm2/statique?page=sdmx",
|
|
||||||
"url": "https://www.bdm.insee.fr/series/sdmx",
|
|
||||||
"headers": {
|
|
||||||
"data": {
|
|
||||||
"Accept": "application/vnd.sdmx.genericdata+xml;version=2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"supports": {
|
|
||||||
"provisionagreement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ISTAT",
|
|
||||||
"name": "Instituto Nationale di Statistica (IT)",
|
|
||||||
"documentation": "https://ec.europa.eu/eurostat/web/sdmx-web-services/rest-sdmx-2.1",
|
|
||||||
"url": "http://sdmx.istat.it/SDMXWS/rest",
|
|
||||||
"supports": {
|
|
||||||
"provisionagreement": false,
|
|
||||||
"structure-specific data": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "OECD",
|
|
||||||
"data_content_type": "JSON",
|
|
||||||
"url": "https://stats.oecd.org/SDMX-JSON",
|
|
||||||
"documentation": "https://stats.oecd.org/SDMX-JSON/",
|
|
||||||
"name": "Organisation for Economic Co-operation and Development"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "NBB",
|
|
||||||
"data_content_type": "JSON",
|
|
||||||
"documentation": "https://www.nbb.be/doc/dq/migratie_belgostat/en/nbb_stat-technical-manual.pdf",
|
|
||||||
"url": "https://stat.nbb.be/sdmx-json",
|
|
||||||
"name": "National Bank of Belgium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "NB",
|
|
||||||
"name": "Norges Bank (NO)",
|
|
||||||
"documentation": "https://www.norges-bank.no/en/topics/Statistics/open-data/",
|
|
||||||
"url": "https://data.norges-bank.no/api",
|
|
||||||
"supports": {
|
|
||||||
"categoryscheme": false,
|
|
||||||
"structure-specific data": true
|
|
||||||
},
|
|
||||||
"headers": {
|
|
||||||
"data": {
|
|
||||||
"accept": "application/vnd.sdmx.genericdata+xml;version=2.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SGR",
|
|
||||||
"url": "https://registry.sdmx.org/ws/rest",
|
|
||||||
"name": "SDMX Global Registry",
|
|
||||||
"documentation": "https://registry.sdmx.org/ws/rest"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UNICEF",
|
|
||||||
"name": "UN International Children's Emergency Fund",
|
|
||||||
"documentation": "https://data.unicef.org/",
|
|
||||||
"url": "https://sdmx.data.unicef.org/ws/public/sdmxapi/rest",
|
|
||||||
"headers": {
|
|
||||||
"accept": "application/vnd.sdmx.structure+xml;version=2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SPC",
|
|
||||||
"name": "Pacific Data Hub",
|
|
||||||
"documentation": "https://stats.pacificdata.org/?locale=en",
|
|
||||||
"url": "https://stats-nsi-stable.pacificdata.org/rest",
|
|
||||||
"supports": {
|
|
||||||
"preview": false,
|
|
||||||
"provisionagreement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UNSD",
|
|
||||||
"name": "United Nations Statistics Division",
|
|
||||||
"documentation": "https://unstats.un.org/home/",
|
|
||||||
"url": "https://data.un.org/WS/rest",
|
|
||||||
"supports": {
|
|
||||||
"preview": true,
|
|
||||||
"provisionagreement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "WB",
|
|
||||||
"name": "World Bank World Integrated Trade Solution",
|
|
||||||
"documentation": "http://wits.worldbank.org",
|
|
||||||
"url": "http://wits.worldbank.org/API/V1/SDMX/V21/rest",
|
|
||||||
"supports": {
|
|
||||||
"agencyscheme": false,
|
|
||||||
"provisionagreement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "WB_WDI",
|
|
||||||
"name": "World Bank World Development Indicators",
|
|
||||||
"documentation": "https://datahelpdesk.worldbank.org/knowledgebase/articles/1886701-sdmx-api-queries",
|
|
||||||
"url": "http://api.worldbank.org/v2/sdmx/rest",
|
|
||||||
"supports": {
|
|
||||||
"provisionagreement": false,
|
|
||||||
"structure-specific data": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "LSD",
|
|
||||||
"documentation": "https://osp.stat.gov.lt/rdb-rest",
|
|
||||||
"url": "https://osp-rs.stat.gov.lt/rest_xml",
|
|
||||||
"name": "Statistics Lithuania",
|
|
||||||
"supports": {
|
|
||||||
"categoryscheme": false,
|
|
||||||
"codelist": false,
|
|
||||||
"conceptscheme": false,
|
|
||||||
"provisionagreement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "STAT_EE",
|
|
||||||
"data_content_type": "JSON",
|
|
||||||
"documentation": "https://www.stat.ee/sites/default/files/2020-09/API-instructions.pdf",
|
|
||||||
"url": "http://andmebaas.stat.ee/sdmx-json",
|
|
||||||
"name": "Statistics Estonia"
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 3.2 on 2021-08-10 21:51
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='researchgroup',
|
||||||
|
options={},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,37 +0,0 @@
|
||||||
# Generated by Django 3.2 on 2021-04-15 14:53
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
('core', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='project',
|
|
||||||
name='collaborators',
|
|
||||||
field=models.ManyToManyField(blank=True, help_text='The users who can edit the project.',
|
|
||||||
related_name='collaborates_in', to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
# No projects should be in the db before this migration is run, so this should be fine
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='project',
|
|
||||||
name='owner',
|
|
||||||
field=models.ForeignKey(default=0, help_text='The user who owns the project, and has full access to it.',
|
|
||||||
on_delete=django.db.models.deletion.CASCADE, to='auth.user'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='project',
|
|
||||||
name='visibility',
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[('PUBLIC', '🌍 Public'), ('INTERNAL', '🏭 Internal'), ('PRIVATE', '🔒 Private')],
|
|
||||||
default='INTERNAL', help_text='A setting specifying who can view the project.', max_length=16,
|
|
||||||
verbose_name='Visibility'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,17 +0,0 @@
|
||||||
# Generated by Django 3.2 on 2021-04-18 17:51
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
('core', '0002_auto_20210415_1453'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name='dataflow',
|
|
||||||
old_name='id',
|
|
||||||
new_name='sdmx_id',
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,18 +0,0 @@
|
||||||
# Generated by Django 3.2 on 2021-04-18 17:52
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
('core', '0003_rename_id_dataflow_sdmx_id'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dataflow',
|
|
||||||
name='surrogate_id',
|
|
||||||
field=models.BigAutoField(help_text='Internal id used by Django to identify this DataFlow.',
|
|
||||||
primary_key=True, serialize=False, verbose_name='Surrogate id'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,41 +0,0 @@
|
||||||
# Generated by Django 3.2 on 2021-08-06 15:06
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
('core', '0004_alter_dataflow_surrogate_id'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ResearchGroup',
|
|
||||||
fields=[
|
|
||||||
('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the group.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')),
|
|
||||||
('name', models.CharField(help_text='The display name of the group.', max_length=512, verbose_name='Name')),
|
|
||||||
('description', models.TextField(blank=True, help_text='A brief description of what the group is about, to be displayed in the overview.', verbose_name='Description')),
|
|
||||||
('access', models.CharField(choices=[('MANUAL', '⛔️ Collaborators must be added manually'), ('OPEN', '❇️ Users can join the group freely')], default='MANUAL', help_text='A setting specifying how can users join this group.', max_length=16, verbose_name='Access')),
|
|
||||||
('members', models.ManyToManyField(blank=True, help_text='The users who belong to this group, including the owner.', related_name='is_a_member_of', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('owner', models.ForeignKey(help_text='The user who created the group, and therefore can add other users to it.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ResearchProject',
|
|
||||||
fields=[
|
|
||||||
('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the project.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')),
|
|
||||||
('name', models.CharField(help_text='The display name of the project.', max_length=512, verbose_name='Name')),
|
|
||||||
('description', models.TextField(blank=True, help_text='A brief description of the project, to be displayed in the overview.', verbose_name='Description')),
|
|
||||||
('visibility', models.CharField(choices=[('PUBLIC', '🌍 Public'), ('INTERNAL', '🏭 Internal'), ('PRIVATE', '🔒 Private')], default='INTERNAL', help_text='A setting specifying who can view the project contents.', max_length=16, verbose_name='Visibility')),
|
|
||||||
('flows', models.ManyToManyField(blank=True, help_text='The DataFlows used in this project.', related_name='used_in', to='core.DataFlow')),
|
|
||||||
('group', models.ForeignKey(help_text='The group this project belongs to.', on_delete=django.db.models.deletion.CASCADE, to='core.researchgroup')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='Project',
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,32 +0,0 @@
|
||||||
# Generated by Django 3.2 on 2021-08-06 19:18
|
|
||||||
|
|
||||||
import colorfield.fields
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
('core', '0005_auto_20210806_1506'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ResearchTag',
|
|
||||||
fields=[
|
|
||||||
('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the tag.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')),
|
|
||||||
('name', models.CharField(help_text='The name of the tag.', max_length=512, verbose_name='Name')),
|
|
||||||
('description', models.TextField(help_text='Additional information about the tag.', verbose_name='Description')),
|
|
||||||
('color', colorfield.fields.ColorField(default='#FF7F00', help_text='The color that the tag should have when displayed.', max_length=18, verbose_name='Color')),
|
|
||||||
('owner', models.ForeignKey(help_text='The user who created the tag, and therefore can delete it.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='researchproject',
|
|
||||||
name='tags',
|
|
||||||
field=models.ManyToManyField(blank=True, help_text='The tags this project has been tagged with.', related_name='tagged', to='core.ResearchTag'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,544 +1,321 @@
|
||||||
import json
|
"""
|
||||||
import logging
|
This module contains the base classes for models in all :mod:`sophon`, and additionally it contains some fundamental models required to have Sophon work
|
||||||
import typing as t
|
properly.
|
||||||
|
"""
|
||||||
|
|
||||||
import pandas
|
from __future__ import annotations
|
||||||
import pandasdmx
|
import typing
|
||||||
import pandasdmx.message
|
import abc
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.contrib.auth.models import User
|
||||||
from colorfield import fields as colorfield_models
|
from rest_framework.serializers import ModelSerializer
|
||||||
|
from sophon.core.enums import SophonGroupAccess
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DataSource(models.Model):
|
class SophonModel(models.Model):
|
||||||
"""
|
"""
|
||||||
A :class:`.DataSource` is a web service which provides access to statistical information sourced by multiple data
|
The **abstract** base class for any database model used by Sophon.
|
||||||
providers.
|
|
||||||
|
|
||||||
PandaSDMX supports natively multiple data sources, listed
|
.. warning:: Since its metaclass is :class:`django.db.ModelBase`, the :class:`abc.ABCMeta` metaclass can't be applied, so method implementation cannot be
|
||||||
`here <https://pandasdmx.readthedocs.io/en/v1.0/sources.html#data-sources>`_ .
|
checked at runtime.
|
||||||
|
|
||||||
They are duplicated in the database to allow for custom sources to be added through the :meth:`pandasdmx.add_source`
|
It implements utilities for serialization and authorization.
|
||||||
method.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = models.CharField(
|
class Meta:
|
||||||
"PandaSDMX id",
|
abstract = True
|
||||||
help_text="Internal id used by PandaSDMX to reference the source.",
|
|
||||||
max_length=16,
|
|
||||||
primary_key=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
name = models.CharField(
|
|
||||||
"Name",
|
|
||||||
help_text="Full length name of the data source.",
|
|
||||||
max_length=512,
|
|
||||||
)
|
|
||||||
|
|
||||||
description = models.TextField(
|
|
||||||
"Description",
|
|
||||||
help_text="Long description of the data source.",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
url = models.URLField(
|
|
||||||
"API URL",
|
|
||||||
help_text="The base URL of the SDMX endpoint of the data source."
|
|
||||||
)
|
|
||||||
|
|
||||||
documentation = models.URLField(
|
|
||||||
"Documentation URL",
|
|
||||||
help_text="Documentation URL of the data source.",
|
|
||||||
null=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
data_content_type = models.CharField(
|
|
||||||
"API type",
|
|
||||||
help_text="The format in which the API returns its data.",
|
|
||||||
choices=[
|
|
||||||
("JSON", "JSON"),
|
|
||||||
("XML", "XML"),
|
|
||||||
],
|
|
||||||
default="XML",
|
|
||||||
max_length=16,
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = models.JSONField(
|
|
||||||
"HTTP Headers",
|
|
||||||
help_text="HTTP headers to attach to every request, as a JSON object.",
|
|
||||||
default=dict,
|
|
||||||
)
|
|
||||||
|
|
||||||
resources = models.JSONField(
|
|
||||||
"Resources",
|
|
||||||
help_text="Unknown and undocumented JSON object.",
|
|
||||||
default=dict,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_agencyscheme = models.BooleanField(
|
|
||||||
"Supports AgencyScheme",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.AgencyScheme">'
|
|
||||||
'AgencyScheme '
|
|
||||||
'</a> or not.',
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_categoryscheme = models.BooleanField(
|
|
||||||
"Supports CategoryScheme",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.CategoryScheme">'
|
|
||||||
'CategoryScheme '
|
|
||||||
'</a> or not.',
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_codelist = models.BooleanField(
|
|
||||||
"Supports CodeList",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.CodeList">'
|
|
||||||
'CodeList '
|
|
||||||
'</a> or not.',
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_conceptscheme = models.BooleanField(
|
|
||||||
"Supports ConceptScheme",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.ConceptScheme">'
|
|
||||||
'ConceptScheme '
|
|
||||||
'</a> or not.',
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_data = models.BooleanField(
|
|
||||||
"Supports DataSet",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataSet">'
|
|
||||||
'DataSet '
|
|
||||||
'</a> or not.',
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_dataflow = models.BooleanField(
|
|
||||||
"Supports DataflowDefinition",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataflowDefinition">'
|
|
||||||
'DataflowDefinition '
|
|
||||||
'</a> or not.',
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_datastructure = models.BooleanField(
|
|
||||||
"Supports DataStructureDefinition",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataStructureDefinition">'
|
|
||||||
'CategoryScheme '
|
|
||||||
'</a> or not.',
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_provisionagreement = models.BooleanField(
|
|
||||||
"Supports ProvisionAgreement",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.ProvisionAgreement">'
|
|
||||||
'CategoryScheme '
|
|
||||||
'</a> or not.',
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_preview = models.BooleanField(
|
|
||||||
"Supports previews",
|
|
||||||
help_text='Whether the data source supports '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.Request.preview_data">'
|
|
||||||
'previews of data '
|
|
||||||
'</a> or not.',
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_structurespecific_data = models.BooleanField(
|
|
||||||
"Supports structure-specific data messages",
|
|
||||||
help_text='Whether the data source returns '
|
|
||||||
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.source.Source">'
|
|
||||||
'structure-specific data messages '
|
|
||||||
'</a> or not.',
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def supports_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"agencyscheme": self.supports_agencyscheme,
|
|
||||||
"categoryscheme": self.supports_categoryscheme,
|
|
||||||
"codelist": self.supports_codelist,
|
|
||||||
"conceptscheme": self.supports_conceptscheme,
|
|
||||||
"data": self.supports_data,
|
|
||||||
"dataflow": self.supports_dataflow,
|
|
||||||
"datastructure": self.supports_datastructure,
|
|
||||||
"provisionagreement": self.supports_provisionagreement,
|
|
||||||
"preview": self.supports_preview,
|
|
||||||
"structure-specific data": self.supports_structurespecific_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
def info_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"name": self.name,
|
|
||||||
"data_content_type": self.data_content_type,
|
|
||||||
"url": self.url,
|
|
||||||
"documentation": self.documentation,
|
|
||||||
"supports": self.supports_dict(),
|
|
||||||
"headers": self.headers,
|
|
||||||
"resources": self.resources,
|
|
||||||
}
|
|
||||||
|
|
||||||
builtin = models.BooleanField(
|
|
||||||
"Builtin",
|
|
||||||
help_text="Whether the source is built-in in PandaSDMX or not.",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_sources_json(cls, file: t.TextIO):
|
@abc.abstractmethod
|
||||||
j_sources: list = json.load(file)
|
def get_fields(cls) -> set[str]:
|
||||||
|
"""
|
||||||
|
:return: The :class:`set` of field names (as :class:`str`) that will be serialized in the ``list`` and ``retrieve`` actions.
|
||||||
|
"""
|
||||||
|
|
||||||
for j_source in j_sources:
|
raise NotImplementedError()
|
||||||
|
|
||||||
# Flatten supports
|
@classmethod
|
||||||
if supports := j_source.get("supports"):
|
def get_view_serializer(cls) -> typing.Type[ModelSerializer]:
|
||||||
del j_source["supports"]
|
"""
|
||||||
for key, value in supports.items():
|
:return: The :class:`.serializers.ModelSerializer` containing this object's fields in _read-only_ mode.
|
||||||
if key == "structure-specific data":
|
"""
|
||||||
j_source["supports_structurespecific_data"] = value
|
|
||||||
|
class ViewSerializer(ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = cls
|
||||||
|
fields = list(cls.get_fields())
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
return ViewSerializer
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_editable_fields(cls) -> set[str]:
|
||||||
|
"""
|
||||||
|
:return: The :class:`set` of field names (as :class:`str`) that users with the **Edit** or **Admin** permission should be able to edit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_non_editable_fields(cls) -> set[str]:
|
||||||
|
"""
|
||||||
|
:return: The :class:`set` of field names (as :class:`str`) that will be _read-only_ for an user with the **Edit** or **Admin** permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return set.difference(
|
||||||
|
cls.get_fields(),
|
||||||
|
cls.get_editable_fields(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def can_edit(self, user: User) -> bool:
|
||||||
|
"""
|
||||||
|
:param user: The user to check the **Edit** permission of.
|
||||||
|
:return: :data:`True` if the user can edit the object's editable fields, :data:`False` otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_edit_serializer(cls) -> typing.Type[ModelSerializer]:
|
||||||
|
"""
|
||||||
|
:return: The :class:`.serializers.ModelSerializer` which allows the user to edit the fields specified in :meth:`.get_editable_fields`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class EditSerializer(ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = cls
|
||||||
|
fields = list(cls.get_fields())
|
||||||
|
read_only_fields = list(cls.get_non_editable_fields())
|
||||||
|
|
||||||
|
return EditSerializer
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_administrable_fields(cls) -> set[str]:
|
||||||
|
"""
|
||||||
|
:return: The :class:`set` of field names (as :class:`str`) that users with the **Admin** permission should be able to edit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_non_administrable_fields(cls) -> set[str]:
|
||||||
|
"""
|
||||||
|
:return: The :class:`set` of field names (as :class:`str`) that will be _read-only_ for an user with the **Admin** permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return set.difference(
|
||||||
|
cls.get_fields(),
|
||||||
|
cls.get_administrable_fields(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def can_admin(self, user: User) -> bool:
|
||||||
|
"""
|
||||||
|
:param user: The user to check the **Admin** permission of.
|
||||||
|
:return: :data:`True` if the user can edit the object's administrable fields, :data:`False` otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_admin_serializer(cls) -> typing.Type[ModelSerializer]:
|
||||||
|
"""
|
||||||
|
:return: A :class:`.serializers.ModelSerializer` which allows the user to edit the fields specified in :meth:`.get_editable_fields` and
|
||||||
|
:meth:`.get_administrable_fields`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class AdminSerializer(ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = cls
|
||||||
|
fields = list(cls.get_fields())
|
||||||
|
read_only_fields = list(cls.get_non_administrable_fields())
|
||||||
|
|
||||||
|
return AdminSerializer
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_creation_fields(cls) -> set[str]:
|
||||||
|
"""
|
||||||
|
:return: The :class:`set` of field names (as :class:`str`) that users should be able to specify when creating a new object of this class.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_creation_serializer(cls) -> typing.Type[ModelSerializer]:
|
||||||
|
"""
|
||||||
|
:return: A :class:`.serializers.ModelSerializer` which allows the user to define the fields specified in :meth:`.get_creation_fields`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class CreateSerializer(ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = cls
|
||||||
|
fields = list(cls.get_creation_fields())
|
||||||
|
|
||||||
|
return CreateSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyAbstractClass
|
||||||
|
class SophonGroupModel(SophonModel):
|
||||||
|
"""
|
||||||
|
The **abstract** base class for database objects belonging to a :class:`.ResearchGroup`.
|
||||||
|
|
||||||
|
.. warning:: Since its metaclass is :class:`django.db.ModelBase`, the :class:`abc.ABCMeta` metaclass can't be applied, so method implementation cannot be
|
||||||
|
checked at runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_group(self) -> ResearchGroup:
|
||||||
|
"""
|
||||||
|
:return: The :class:`.ResearchGroup` this objects belongs to.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_access_to_edit(cls) -> SophonGroupAccess:
|
||||||
|
"""
|
||||||
|
:return: The minimum required :class:`.SophonGroupAccess` to **Edit** this object.
|
||||||
|
"""
|
||||||
|
return SophonGroupAccess.MEMBER
|
||||||
|
|
||||||
|
def can_edit(self, user: User) -> bool:
|
||||||
|
current = self.get_group().get_access(user)
|
||||||
|
required = self.get_access_to_edit()
|
||||||
|
return current >= required
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_access_to_admin(cls) -> SophonGroupAccess:
|
||||||
|
"""
|
||||||
|
:return: The minimum required :class:`.SophonGroupAccess` to **Admin**\\ istrate this object.
|
||||||
|
"""
|
||||||
|
return SophonGroupAccess.OWNER
|
||||||
|
|
||||||
|
def can_admin(self, user: User) -> bool:
|
||||||
|
current = self.get_group().get_access(user)
|
||||||
|
required = self.get_access_to_admin()
|
||||||
|
return current >= required
|
||||||
|
|
||||||
|
def get_access_serializer(self, user: User) -> typing.Type[ModelSerializer]:
|
||||||
|
"""
|
||||||
|
Select a :class:`.serializers.ModelSerializer` for this object based on the :class:`.User`\\ 's :class:`.SophonGroupAccess` on it.
|
||||||
|
|
||||||
|
:param user: The :class:`.User` to select a serializer for.
|
||||||
|
:return: The selected :class:`.serializers.ModelSerializer`.
|
||||||
|
"""
|
||||||
|
if self.can_admin(user):
|
||||||
|
return self.get_admin_serializer()
|
||||||
|
elif self.can_edit(user):
|
||||||
|
return self.get_edit_serializer()
|
||||||
else:
|
else:
|
||||||
j_source[f"supports_{key}"] = value
|
return self.get_view_serializer()
|
||||||
|
|
||||||
cls.objects.update_or_create(
|
|
||||||
id=j_source["id"],
|
|
||||||
defaults={
|
|
||||||
**j_source,
|
|
||||||
"builtin": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_pandasdmx_source(self) -> pandasdmx.source.Source:
|
|
||||||
"""
|
|
||||||
Convert the :class:`.DataSource` to a :class:`pandasdmx.source.Source`\\ .
|
|
||||||
|
|
||||||
:return: The :class:`pandasdmx.source.Source`\\ .
|
|
||||||
|
|
||||||
.. todo:: :func:`.to_pandasdmx` does not currently support non :attr:`.builtin` sources.
|
|
||||||
"""
|
|
||||||
return pandasdmx.source.sources[self.id]
|
|
||||||
|
|
||||||
def to_pandasdmx_request(self) -> pandasdmx.Request:
|
|
||||||
"""
|
|
||||||
Convert the :class:`.DataSource` to a :class:`pandasdmx.Request` client.
|
|
||||||
|
|
||||||
:return: The :class:`pandasdmx.Request`\\ .
|
|
||||||
"""
|
|
||||||
return pandasdmx.Request(source=self.to_pandasdmx_source().id)
|
|
||||||
|
|
||||||
last_sync = models.DateTimeField(
|
|
||||||
"Last updated",
|
|
||||||
help_text="The datetime at which the data flows of this source were last syncronized.",
|
|
||||||
null=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def request_flows(self) -> tuple[pandas.Series, pandas.Series]:
|
|
||||||
"""
|
|
||||||
Retrieve all available dataflows and datastructures as two :class:`pandas.Series`\\ .
|
|
||||||
|
|
||||||
:return: A :class:`tuple` containing all dataflows and all datastructures.
|
|
||||||
|
|
||||||
.. note:: This seems to be an expensive operation, as it may take a few minutes to execute.
|
|
||||||
|
|
||||||
.. todo:: This function assumes both ``dataflow`` and ``structure`` will always be available.
|
|
||||||
Can something happen to make at least one of them :data:`None` ?
|
|
||||||
"""
|
|
||||||
source = self.to_pandasdmx_request()
|
|
||||||
message: pandasdmx.message.Message = source.dataflow()
|
|
||||||
data: dict[str, pandas.Series] = message.to_pandas()
|
|
||||||
flows = data["dataflow"]
|
|
||||||
structs = data["structure"]
|
|
||||||
return flows, structs
|
|
||||||
|
|
||||||
def sync_flows(self) -> None:
|
|
||||||
"""
|
|
||||||
Create :class:`.DataFlow` objects for every dataflow returned by :meth:`.request_flows`, and update the ones
|
|
||||||
that already exist.
|
|
||||||
|
|
||||||
.. warning:: This function does not delete any :class:`.DataFlow`, even if it doesn't exist anymore!
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"Requesting dataflows of {self!r}...")
|
|
||||||
flows, structs = self.request_flows()
|
|
||||||
|
|
||||||
log.info(f"Syncing DataFlows of {self!r}...")
|
|
||||||
for description, sdmx_id in zip(flows, flows.index):
|
|
||||||
db_flow, _created = DataFlow.objects.update_or_create(
|
|
||||||
**{
|
|
||||||
"datasource": self,
|
|
||||||
"sdmx_id": sdmx_id,
|
|
||||||
},
|
|
||||||
defaults={
|
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
db_flow.save()
|
|
||||||
log.debug(f"Synced {db_flow}!")
|
|
||||||
|
|
||||||
log.debug(f"Updating last_sync value of {self!r}")
|
|
||||||
self.last_sync = timezone.now()
|
|
||||||
self.save()
|
|
||||||
log.info(f"Finished syncing DataFlows of {self!r}")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.id
|
|
||||||
|
|
||||||
|
|
||||||
class DataFlow(models.Model):
|
class ResearchGroup(SophonGroupModel):
|
||||||
"""
|
|
||||||
A :class:`.DataFlow` is a object containing the metadata of a SDMX data set.
|
|
||||||
|
|
||||||
See `this page <https://ec.europa.eu/eurostat/online-help/redisstat-admin/en/TECH_A_main/>`_ for more details.
|
|
||||||
"""
|
|
||||||
|
|
||||||
surrogate_id = models.BigAutoField(
|
|
||||||
"Surrogate id",
|
|
||||||
help_text="Internal id used by Django to identify this DataFlow.",
|
|
||||||
primary_key=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
datasource = models.ForeignKey(
|
|
||||||
DataSource,
|
|
||||||
help_text="The DataSource this object belongs to.",
|
|
||||||
on_delete=models.RESTRICT,
|
|
||||||
)
|
|
||||||
|
|
||||||
sdmx_id = models.CharField(
|
|
||||||
"SDMX id",
|
|
||||||
help_text="Internal string used in SDMX communication to identify the DataFlow.",
|
|
||||||
max_length=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
description = models.TextField(
|
|
||||||
"Description",
|
|
||||||
help_text="Natural language description of the DataFlow.",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"[{self.datasource}] {self.sdmx_id}"
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchGroup(models.Model):
|
|
||||||
"""
|
"""
|
||||||
A :class:`.ResearchGroup` is a group of users which collectively own :class:`.ResearchProjects`.
|
A :class:`.ResearchGroup` is a group of users which collectively own :class:`.ResearchProjects`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
slug = models.SlugField(
|
slug = models.SlugField(
|
||||||
"Slug",
|
"Slug",
|
||||||
help_text="Unique alphanumeric string which identifies the group.",
|
help_text="Unique alphanumeric string which identifies the group in the Sophon instance.",
|
||||||
max_length=64,
|
max_length=64,
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
"Name",
|
"Name",
|
||||||
help_text="The display name of the group.",
|
help_text="The displayed name of the group.",
|
||||||
max_length=512,
|
max_length=512,
|
||||||
)
|
)
|
||||||
|
|
||||||
description = models.TextField(
|
description = models.TextField(
|
||||||
"Description",
|
"Description",
|
||||||
help_text="A brief description of what the group is about, to be displayed in the overview.",
|
help_text="A brief description of what the group is about.",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
members = models.ManyToManyField(
|
||||||
|
User,
|
||||||
|
help_text="The users who belong to this group.",
|
||||||
|
related_name="is_a_member_of",
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
help_text="The user who created the group, and therefore can add other users to it.",
|
help_text="The user who created the group, who is automatically a member.",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
|
|
||||||
members = models.ManyToManyField(
|
|
||||||
User,
|
|
||||||
help_text="The users who belong to this group, including the owner.",
|
|
||||||
related_name="is_a_member_of",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
access = models.CharField(
|
access = models.CharField(
|
||||||
"Access",
|
"Access",
|
||||||
help_text="A setting specifying how can users join this group.",
|
help_text="A setting specifying how users can join the group.",
|
||||||
choices=[
|
choices=[
|
||||||
("MANUAL", "⛔️ Collaborators must be added manually"),
|
("MANUAL", "⛔️ Collaborators must be added manually"),
|
||||||
|
# ("REQUEST", "✉️ Users can request an invite from the group owner"),
|
||||||
("OPEN", "❇️ Users can join the group freely"),
|
("OPEN", "❇️ Users can join the group freely"),
|
||||||
],
|
],
|
||||||
default="MANUAL",
|
default="MANUAL",
|
||||||
max_length=16,
|
max_length=16,
|
||||||
)
|
)
|
||||||
|
|
||||||
def can_be_edited_by(self, user) -> bool:
|
def get_group(self) -> ResearchGroup:
|
||||||
if user.is_superuser:
|
return self
|
||||||
return True
|
|
||||||
|
|
||||||
elif user in self.members:
|
@classmethod
|
||||||
return True
|
def get_fields(cls) -> set[str]:
|
||||||
|
return {
|
||||||
|
"slug",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"owner",
|
||||||
|
"members",
|
||||||
|
"access",
|
||||||
|
}
|
||||||
|
|
||||||
return False
|
@classmethod
|
||||||
|
def get_editable_fields(cls) -> set[str]:
|
||||||
|
return {
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
}
|
||||||
|
|
||||||
def can_be_administrated_by(self, user) -> bool:
|
@classmethod
|
||||||
if user.is_superuser:
|
def get_administrable_fields(cls) -> set[str]:
|
||||||
return True
|
return {
|
||||||
|
"members",
|
||||||
|
"access",
|
||||||
|
}
|
||||||
|
|
||||||
elif user in self.owner:
|
@classmethod
|
||||||
return True
|
def get_creation_fields(cls) -> set[str]:
|
||||||
|
return {
|
||||||
|
"slug",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"members",
|
||||||
|
"access",
|
||||||
|
}
|
||||||
|
|
||||||
return False
|
def get_access(self, user) -> SophonGroupAccess:
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.slug}"
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchTag(models.Model):
|
|
||||||
"""
|
"""
|
||||||
A :class:`.ResearchTag` is a keyword that :class:`.ResearchProject`\\ s can be associated with.
|
Get the :class:`SophonGroupAccess` that an user has on this group.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
slug = models.SlugField(
|
|
||||||
"Slug",
|
|
||||||
help_text="Unique alphanumeric string which identifies the tag.",
|
|
||||||
max_length=64,
|
|
||||||
primary_key=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
name = models.CharField(
|
|
||||||
"Name",
|
|
||||||
help_text="The name of the tag.",
|
|
||||||
max_length=512,
|
|
||||||
)
|
|
||||||
|
|
||||||
description = models.TextField(
|
|
||||||
"Description",
|
|
||||||
help_text="Additional information about the tag.",
|
|
||||||
)
|
|
||||||
|
|
||||||
color = colorfield_models.ColorField(
|
|
||||||
"Color",
|
|
||||||
help_text="The color that the tag should have when displayed.",
|
|
||||||
default="#FF7F00",
|
|
||||||
)
|
|
||||||
|
|
||||||
owner = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
help_text="The user who created the tag, and therefore can delete it.",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
|
|
||||||
def can_be_administrated_by(self, user) -> bool:
|
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return True
|
return SophonGroupAccess.SUPERUSER
|
||||||
|
|
||||||
elif user == self.owner:
|
elif user == self.owner:
|
||||||
return True
|
return SophonGroupAccess.OWNER
|
||||||
|
elif user in self.members.all():
|
||||||
return False
|
return SophonGroupAccess.MEMBER
|
||||||
|
elif not user.is_anonymous:
|
||||||
def __str__(self):
|
return SophonGroupAccess.REGISTERED
|
||||||
return f"[{self.name}]"
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchProject(models.Model):
|
|
||||||
"""
|
|
||||||
A :class:`.ResearchProject` is a work which may use zero or more :class:`.DataSource`\\ s to prove or disprove an
|
|
||||||
hypothesis.
|
|
||||||
"""
|
|
||||||
|
|
||||||
slug = models.SlugField(
|
|
||||||
"Slug",
|
|
||||||
help_text="Unique alphanumeric string which identifies the project.",
|
|
||||||
max_length=64,
|
|
||||||
primary_key=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
name = models.CharField(
|
|
||||||
"Name",
|
|
||||||
help_text="The display name of the project.",
|
|
||||||
max_length=512,
|
|
||||||
)
|
|
||||||
|
|
||||||
description = models.TextField(
|
|
||||||
"Description",
|
|
||||||
help_text="A brief description of the project, to be displayed in the overview.",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
visibility = models.CharField(
|
|
||||||
"Visibility",
|
|
||||||
help_text="A setting specifying who can view the project contents.",
|
|
||||||
choices=[
|
|
||||||
("PUBLIC", "🌍 Public"),
|
|
||||||
("INTERNAL", "🏭 Internal"),
|
|
||||||
("PRIVATE", "🔒 Private"),
|
|
||||||
],
|
|
||||||
default="INTERNAL",
|
|
||||||
max_length=16,
|
|
||||||
)
|
|
||||||
|
|
||||||
group = models.ForeignKey(
|
|
||||||
ResearchGroup,
|
|
||||||
help_text="The group this project belongs to.",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
|
|
||||||
tags = models.ManyToManyField(
|
|
||||||
ResearchTag,
|
|
||||||
help_text="The tags this project has been tagged with.",
|
|
||||||
related_name="tagged",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
flows = models.ManyToManyField(
|
|
||||||
DataFlow,
|
|
||||||
help_text="The DataFlows used in this project.",
|
|
||||||
related_name="used_in",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def can_be_viewed_by(self, user) -> bool:
|
|
||||||
if user.is_superuser:
|
|
||||||
return True
|
|
||||||
|
|
||||||
elif self.visibility == "PUBLIC":
|
|
||||||
return True
|
|
||||||
elif self.visibility == "INTERNAL":
|
|
||||||
return not user.is_anonymous()
|
|
||||||
elif self.visibility == "PRIVATE":
|
|
||||||
return user in self.group.members
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown visibility value: {self.visibility}")
|
return SophonGroupAccess.NONE
|
||||||
|
|
||||||
def can_be_edited_by(self, user) -> bool:
|
|
||||||
if user.is_superuser:
|
|
||||||
return True
|
|
||||||
|
|
||||||
elif user in self.group.members:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def can_be_administrated_by(self, user) -> bool:
|
|
||||||
if user.is_superuser:
|
|
||||||
return True
|
|
||||||
|
|
||||||
elif user in self.group.members:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.slug}"
|
return f"{self.name}"
|
||||||
|
|
32
backend/sophon/core/permissions.py
Normal file
32
backend/sophon/core/permissions.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
"""
|
||||||
|
This module contains some extensions to the :mod:`rest_framework.permissions` framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework.permissions import BasePermission, AllowAny
|
||||||
|
|
||||||
|
|
||||||
|
class Edit(BasePermission):
|
||||||
|
"""
|
||||||
|
Authorize only users who :meth:`~sophon.core.models.SophonGroupModel.can_edit` the requested object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
return obj.can_edit(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class Admin(BasePermission):
|
||||||
|
"""
|
||||||
|
Authorize only users who :meth:`~sophon.core.models.SophonGroupModel.can_admin` the requested object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
return obj.can_admin(request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# Specify __all__ so that this can be imported as *
|
||||||
|
__all__ = (
|
||||||
|
"BasePermission",
|
||||||
|
"AllowAny",
|
||||||
|
"Edit",
|
||||||
|
"Admin",
|
||||||
|
)
|
|
@ -1,234 +1,9 @@
|
||||||
from rest_framework import serializers
|
from rest_framework.serializers import Serializer
|
||||||
|
|
||||||
from . import models
|
|
||||||
|
|
||||||
|
|
||||||
class DataSourceSerializer(serializers.ModelSerializer):
|
class NoneSerializer(Serializer):
|
||||||
"""
|
def update(self, instance, validated_data):
|
||||||
Serializer for :class:`.models.DataSource` .
|
return None
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
def create(self, validated_data):
|
||||||
model = models.DataSource
|
return None
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"url",
|
|
||||||
"documentation",
|
|
||||||
"data_content_type",
|
|
||||||
"headers",
|
|
||||||
"supports_agencyscheme",
|
|
||||||
"supports_categoryscheme",
|
|
||||||
"supports_codelist",
|
|
||||||
"supports_conceptscheme",
|
|
||||||
"supports_data",
|
|
||||||
"supports_dataflow",
|
|
||||||
"supports_datastructure",
|
|
||||||
"supports_provisionagreement",
|
|
||||||
"supports_preview",
|
|
||||||
"supports_structurespecific_data",
|
|
||||||
"builtin",
|
|
||||||
"last_sync",
|
|
||||||
]
|
|
||||||
read_only_fields = [
|
|
||||||
"builtin",
|
|
||||||
"last_sync",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class DataFlowSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for :class:`.models.DataFlow` .
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.DataFlow
|
|
||||||
fields = [
|
|
||||||
"surrogate_id",
|
|
||||||
"datasource",
|
|
||||||
"sdmx_id",
|
|
||||||
"description",
|
|
||||||
]
|
|
||||||
read_only_fields = [
|
|
||||||
"surrogate_id",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchGroupPublicSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for users who are not administrators of a :class:`.models.ResearchGroup`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ResearchGroup
|
|
||||||
fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"owner",
|
|
||||||
"members",
|
|
||||||
"access",
|
|
||||||
)
|
|
||||||
read_only_fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"owner",
|
|
||||||
"members",
|
|
||||||
"access",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchGroupAdminSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for users who are administrators of a :class:`.models.ResearchGroup`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ResearchGroup
|
|
||||||
fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"owner",
|
|
||||||
"members",
|
|
||||||
"access",
|
|
||||||
)
|
|
||||||
read_only_fields = (
|
|
||||||
"slug",
|
|
||||||
"owner",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchProjectPublicSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for users who are not collaborators of a :class:`~.models.ResearchProject` and do not have permissions to view it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ResearchProject
|
|
||||||
fields = (
|
|
||||||
"slug",
|
|
||||||
"visibility",
|
|
||||||
"group",
|
|
||||||
)
|
|
||||||
read_only_fields = (
|
|
||||||
"slug",
|
|
||||||
"visibility",
|
|
||||||
"group",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchTagPublicSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for users who are not owners of a :class:`.models.ResearchTag`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# TODO: Add a list of projects with the tag
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ResearchTag
|
|
||||||
fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"color",
|
|
||||||
"owner",
|
|
||||||
)
|
|
||||||
read_only_fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"color",
|
|
||||||
"owner",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchTagAdminSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for users who are owners of a :class:`.models.ResearchTag`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# TODO: Add a list of projects with the tag
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ResearchTag
|
|
||||||
fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"color",
|
|
||||||
"owner",
|
|
||||||
)
|
|
||||||
read_only_fields = (
|
|
||||||
"slug",
|
|
||||||
"owner",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchProjectViewerSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for users who are not collaborators of a :class:`~.models.ResearchProject`, but have permissions to view it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ResearchProject
|
|
||||||
fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"visibility",
|
|
||||||
"group",
|
|
||||||
"flows",
|
|
||||||
)
|
|
||||||
read_only_fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"visibility",
|
|
||||||
"group",
|
|
||||||
"flows",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchProjectCollaboratorSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for users who are collaborators of a :class:`~.models.ResearchProject`, but not administrators.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ResearchProject
|
|
||||||
fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"visibility",
|
|
||||||
"group",
|
|
||||||
"flows",
|
|
||||||
)
|
|
||||||
read_only_fields = (
|
|
||||||
"slug",
|
|
||||||
"visibility",
|
|
||||||
"group",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchProjectAdminSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for users who are administrators of a :class:`~.models.ResearchProject`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ResearchProject
|
|
||||||
fields = (
|
|
||||||
"slug",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"visibility",
|
|
||||||
"group",
|
|
||||||
"flows",
|
|
||||||
)
|
|
||||||
read_only_fields = (
|
|
||||||
"slug",
|
|
||||||
)
|
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
import rest_framework.routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register("datasources", views.DataSourceViewSet)
|
router = rest_framework.routers.DefaultRouter()
|
||||||
router.register("dataflows", views.DataFlowViewSet)
|
router.register("groups", views.ResearchGroupViewSet, basename="research-group")
|
||||||
router.register("projects", views.ResearchProjectViewSet)
|
|
||||||
router.register("tags", views.ResearchTagViewSet)
|
|
||||||
router.register("groups", views.ResearchGroupViewSet)
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
|
|
|
@ -1,171 +1,247 @@
|
||||||
from logging import getLogger
|
import abc
|
||||||
|
import typing as t
|
||||||
|
|
||||||
from rest_framework import viewsets, decorators, response, permissions, request as r
|
import deprecation
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.serializers import Serializer
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
from . import models, serializers
|
from . import models
|
||||||
from .. import permissions as custom_permissions
|
from . import permissions
|
||||||
|
from . import serializers
|
||||||
log = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchProjectViewSet(viewsets.ModelViewSet):
|
class HTTPException(Exception):
|
||||||
queryset = models.ResearchProject.objects.all()
|
"""
|
||||||
|
An exception that can be raised in :class:`.SophonViewSet` hooks to respond to a request with an HTTP error.
|
||||||
|
"""
|
||||||
|
def __init__(self, status: int):
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def as_response(self) -> Response:
|
||||||
|
return Response(status=self.status)
|
||||||
|
|
||||||
|
|
||||||
|
class SophonViewSet(ModelViewSet, metaclass=abc.ABCMeta):
|
||||||
|
"""
|
||||||
|
An extension to :class:`~rest_framework.viewsets.ModelViewSet` including some essential (but missing) methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A QuerySet should be specified, probably
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_queryset(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
# Override the permission_classes property with this hack, as ModelViewSet doesn't have the get_permission_classes method yet
|
||||||
@property
|
@property
|
||||||
def permission_classes(self):
|
def permission_classes(self):
|
||||||
return {
|
return self.get_permission_classes()
|
||||||
"list": [],
|
|
||||||
"create": [permissions.IsAuthenticated],
|
|
||||||
"retrieve": [custom_permissions.CanView],
|
|
||||||
"update": [custom_permissions.CanEdit],
|
|
||||||
"partial_update": [custom_permissions.CanEdit],
|
|
||||||
"destroy": [custom_permissions.CanAdministrate],
|
|
||||||
"metadata": [],
|
|
||||||
None: [],
|
|
||||||
}[self.action]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
# noinspection PyMethodMayBeStatic
|
||||||
if self.action == "list":
|
def get_permission_classes(self) -> t.Collection[t.Type[permissions.BasePermission]]:
|
||||||
return serializers.ResearchProjectPublicSerializer
|
|
||||||
elif self.action == "create":
|
|
||||||
return serializers.ResearchProjectAdminSerializer
|
|
||||||
else:
|
|
||||||
project = self.get_object()
|
|
||||||
user = self.request.user
|
|
||||||
if project.can_be_administrated_by(user):
|
|
||||||
return serializers.ResearchProjectAdminSerializer
|
|
||||||
elif project.can_be_edited_by(user):
|
|
||||||
return serializers.ResearchProjectCollaboratorSerializer
|
|
||||||
elif project.can_be_viewed_by(user):
|
|
||||||
return serializers.ResearchProjectViewerSerializer
|
|
||||||
else:
|
|
||||||
return serializers.ResearchProjectPublicSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchGroupViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = models.ResearchGroup.objects.all()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def permission_classes(self):
|
|
||||||
return {
|
|
||||||
"list": [],
|
|
||||||
"create": [permissions.IsAuthenticated],
|
|
||||||
"retrieve": [permissions.IsAuthenticated],
|
|
||||||
"update": [custom_permissions.CanAdministrate],
|
|
||||||
"partial_update": [custom_permissions.CanAdministrate],
|
|
||||||
"destroy": [custom_permissions.CanAdministrate],
|
|
||||||
"metadata": [],
|
|
||||||
None: [],
|
|
||||||
}[self.action]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == "list":
|
|
||||||
return serializers.ResearchGroupPublicSerializer
|
|
||||||
elif self.action == "create":
|
|
||||||
return serializers.ResearchGroupAdminSerializer
|
|
||||||
else:
|
|
||||||
group = self.get_object()
|
|
||||||
user = self.request.user
|
|
||||||
if group.can_be_administrated_by(user):
|
|
||||||
return serializers.ResearchGroupAdminSerializer
|
|
||||||
else:
|
|
||||||
return serializers.ResearchGroupPublicSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class ResearchTagViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = models.ResearchTag.objects.all()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def permission_classes(self):
|
|
||||||
return {
|
|
||||||
"list": [],
|
|
||||||
"create": [permissions.IsAuthenticated],
|
|
||||||
"retrieve": [permissions.IsAuthenticated],
|
|
||||||
"update": [custom_permissions.CanAdministrate],
|
|
||||||
"partial_update": [custom_permissions.CanAdministrate],
|
|
||||||
"destroy": [custom_permissions.CanAdministrate],
|
|
||||||
"metadata": [],
|
|
||||||
None: [],
|
|
||||||
}[self.action]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == "list":
|
|
||||||
return serializers.ResearchTagPublicSerializer
|
|
||||||
elif self.action == "create":
|
|
||||||
return serializers.ResearchTagAdminSerializer
|
|
||||||
else:
|
|
||||||
group = self.get_object()
|
|
||||||
user = self.request.user
|
|
||||||
if group.can_be_administrated_by(user):
|
|
||||||
return serializers.ResearchTagAdminSerializer
|
|
||||||
else:
|
|
||||||
return serializers.ResearchTagPublicSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class DataFlowViewSet(viewsets.ModelViewSet):
|
|
||||||
"""
|
"""
|
||||||
Viewset for :class:`.models.DataFlow` instances.
|
The "method" version of the :attr:`~rest_framework.viewsets.ModelViewSet.permission_classes` property.
|
||||||
|
|
||||||
|
:return: A collection of permission classes.
|
||||||
"""
|
"""
|
||||||
|
return permissions.AllowAny,
|
||||||
|
|
||||||
queryset = models.DataFlow.objects.all()
|
# noinspection PyMethodMayBeStatic
|
||||||
serializer_class = serializers.DataFlowSerializer
|
def hook_create(self, serializer) -> dict[str, t.Any]:
|
||||||
permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
|
|
||||||
|
|
||||||
@decorators.action(methods=["get"], detail=False)
|
|
||||||
def search(self, request: r.Request, *args, **kwargs):
|
|
||||||
"""
|
"""
|
||||||
Use Django and PostgreSQL's full text search capabilities to find DataFlows containing certain words in the
|
Hook called on ``create`` actions after the serializer is validated but before it is saved.
|
||||||
description.
|
|
||||||
|
:param serializer: The validated serializer containing the data of the object about to be created.
|
||||||
|
:raises HTTPException: If the request should be answered with an error.
|
||||||
|
:return: A :class:`dict` of fields to be added / overriden to the object saved by the serializer.
|
||||||
"""
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
log.debug("Searching DataFlows...")
|
@deprecation.deprecated(details="Use `.hook_create()` instead.")
|
||||||
if not (query := request.query_params.get("q")):
|
def perform_create(self, serializer):
|
||||||
return response.Response({
|
|
||||||
"success": False,
|
|
||||||
"error": "No query was specified in the `q` query_param."
|
|
||||||
}, 400)
|
|
||||||
|
|
||||||
results = models.DataFlow.objects.filter(description__search=query)
|
|
||||||
page = self.paginate_queryset(results)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True)
|
|
||||||
return self.get_paginated_response(serializer.data)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(results, many=True)
|
|
||||||
return response.Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
class DataSourceViewSet(viewsets.ModelViewSet):
|
|
||||||
"""
|
"""
|
||||||
Viewset for :class:`.models.DataSource` instances.
|
.. warning:: This function does nothing and may not be called on :class:`SophonViewSet`\\ s.
|
||||||
"""
|
"""
|
||||||
|
raise RuntimeError(f"`perform_create` may not be called on `SophonViewSet`s.")
|
||||||
|
|
||||||
queryset = models.DataSource.objects.all()
|
def create(self, request, *args, **kwargs):
|
||||||
serializer_class = serializers.DataSourceSerializer
|
serializer: Serializer = self.get_serializer(data=request.data)
|
||||||
permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@decorators.action(methods=["post"], detail=True)
|
|
||||||
def sync(self, request: r.Request, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Syncronize the :class:`.models.DataFlow`\\ s with the ones stored in the server of the
|
|
||||||
:class:`.models.DataSource`\\ .
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"Getting DataSource from the database...")
|
|
||||||
db_datasource: models.DataSource = self.get_object()
|
|
||||||
try:
|
try:
|
||||||
db_datasource.sync_flows()
|
hook = self.hook_create(serializer)
|
||||||
except NotImplementedError:
|
except HTTPException as e:
|
||||||
return response.Response({
|
return e.as_response()
|
||||||
"success": False,
|
|
||||||
"error": "Syncing DataFlows is not supported on this DataSource."
|
|
||||||
}, 400)
|
|
||||||
except Exception as exc:
|
|
||||||
return response.Response({
|
|
||||||
"success": False,
|
|
||||||
"error": f"{exc}"
|
|
||||||
}, 500)
|
|
||||||
|
|
||||||
return response.Response({
|
serializer.save(**hook)
|
||||||
"success": True,
|
headers = self.get_success_headers(serializer.data)
|
||||||
})
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
# noinspection PyMethodMayBeStatic
|
||||||
|
def hook_update(self, serializer) -> dict[str, t.Any]:
|
||||||
|
"""
|
||||||
|
Hook called on ``update`` and ``partial_update`` actions after the serializer is validated but before it is saved.
|
||||||
|
|
||||||
|
:param serializer: The validated serializer containing the data of the object about to be updated.
|
||||||
|
:raises HTTPException: If the request should be answered with an error.
|
||||||
|
:return: A :class:`dict` of fields to be added / overriden to the object saved by the serializer.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@deprecation.deprecated(details="Use `.hook_update()` instead.")
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
"""
|
||||||
|
.. warning:: This function does nothing and may not be called on :class:`SophonViewSet`\\ s.
|
||||||
|
"""
|
||||||
|
raise RuntimeError(f"`perform_update` may not be called on `SophonViewSet`s.")
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
partial = kwargs.pop('partial', False)
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
hook = self.hook_update(serializer)
|
||||||
|
except HTTPException as e:
|
||||||
|
return e.as_response()
|
||||||
|
|
||||||
|
serializer.save(**hook)
|
||||||
|
|
||||||
|
if getattr(instance, '_prefetched_objects_cache', None):
|
||||||
|
# If 'prefetch_related' has been applied to a queryset, we need to
|
||||||
|
# forcibly invalidate the prefetch cache on the instance.
|
||||||
|
instance._prefetched_objects_cache = {}
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
# noinspection PyMethodMayBeStatic
|
||||||
|
def hook_destroy(self) -> None:
|
||||||
|
"""
|
||||||
|
Hook called on ``destroy`` before the object is deleted.
|
||||||
|
|
||||||
|
:raises HTTPException: If the request should be answered with an error.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@deprecation.deprecated(details="Use `.hook_destroy()` instead.")
|
||||||
|
def perform_destroy(self, serializer):
|
||||||
|
"""
|
||||||
|
.. warning:: This function does nothing and may not be called on :class:`SophonViewSet`\\ s.
|
||||||
|
"""
|
||||||
|
raise RuntimeError(f"`perform_destroy` may not be called on `SophonViewSet`s.")
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.hook_destroy()
|
||||||
|
except HTTPException as e:
|
||||||
|
return e.as_response()
|
||||||
|
|
||||||
|
self.perform_destroy(instance)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action in ["list"]:
|
||||||
|
return self.get_queryset().model.get_view_serializer()
|
||||||
|
elif self.action in ["create", "metadata"]:
|
||||||
|
return self.get_queryset().model.get_creation_serializer()
|
||||||
|
elif self.action in ["retrieve", "update", "partial_update", "destroy"]:
|
||||||
|
return self.get_object().get_access_serializer(self.request.user)
|
||||||
|
else:
|
||||||
|
return self.get_custom_serializer_classes()
|
||||||
|
|
||||||
|
def get_custom_serializer_classes(self):
|
||||||
|
"""
|
||||||
|
.. todo:: Define this.
|
||||||
|
"""
|
||||||
|
return serializers.NoneSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchGroupViewSet(SophonViewSet):
|
||||||
|
"""
|
||||||
|
The viewset for :class:`~.models.ResearchGroup`\\ s.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# All research groups are public, so it's fine to do this
|
||||||
|
return models.ResearchGroup.objects.all()
|
||||||
|
|
||||||
|
def hook_create(self, serializer) -> dict[str, t.Any]:
|
||||||
|
# Add the owner field to the serializer
|
||||||
|
return {
|
||||||
|
"owner": self.request.user,
|
||||||
|
}
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], name="Join group")
|
||||||
|
def join(self, request, pk) -> Response:
|
||||||
|
group = models.ResearchGroup.objects.get(pk=pk)
|
||||||
|
|
||||||
|
# Raise an error if the user is already in the group
|
||||||
|
if self.request.user in group.members.all():
|
||||||
|
return Response(status=status.HTTP_409_CONFLICT)
|
||||||
|
|
||||||
|
# Raise an error if the group doesn't allow member joins
|
||||||
|
if group.access != "OPEN":
|
||||||
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
# Add the user to the group
|
||||||
|
group.members.add(self.request.user)
|
||||||
|
|
||||||
|
return Response(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["delete"], name="Leave group")
|
||||||
|
def leave(self, request, pk):
|
||||||
|
group = models.ResearchGroup.objects.get(pk=pk)
|
||||||
|
|
||||||
|
# Raise an error if the user is not in the group
|
||||||
|
if self.request.user not in group.members.all():
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT)
|
||||||
|
|
||||||
|
# Raise an error if the user is the owner of the group
|
||||||
|
if self.request.user == group.owner:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
# Add the user to the group
|
||||||
|
group.members.remove(self.request.user)
|
||||||
|
|
||||||
|
return Response(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class SophonGroupViewSet(SophonViewSet, metaclass=abc.ABCMeta):
|
||||||
|
"""
|
||||||
|
A :class:`ModelViewSet` for objects belonging to a :class:`~.models.ResearchGroup`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_group_from_serializer(self, serializer) -> models.ResearchGroup:
|
||||||
|
"""
|
||||||
|
:param serializer: The validated serializer containing the data of the object about to be created.
|
||||||
|
:return: The group the data in the serializer refers to.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def hook_create(self, serializer) -> dict[str, t.Any]:
|
||||||
|
# Allow creation of objects only on groups the user has Edit access on
|
||||||
|
group = self.get_group_from_serializer(serializer)
|
||||||
|
if not group.can_edit(self.request.user):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def hook_update(self, serializer) -> dict[str, t.Any]:
|
||||||
|
# Allow group transfers only to groups the user has Edit access on
|
||||||
|
group: models.ResearchGroup = self.get_group_from_serializer(serializer)
|
||||||
|
if not group.can_edit(self.request.user):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_permission_classes(self) -> t.Collection[t.Type[permissions.BasePermission]]:
|
||||||
|
if self.action in ["destroy", "update", "partial_update"]:
|
||||||
|
return permissions.Edit,
|
||||||
|
else:
|
||||||
|
return permissions.AllowAny,
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from rest_framework import permissions
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CanAdministrate(permissions.BasePermission):
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
return obj.can_be_administrated_by(request.user)
|
|
||||||
|
|
||||||
|
|
||||||
class CanEdit(permissions.BasePermission):
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
return obj.can_be_edited_by(request.user)
|
|
||||||
|
|
||||||
|
|
||||||
class CanView(permissions.BasePermission):
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
return obj.can_be_viewed_by(request.user)
|
|
0
backend/sophon/projects/__init__.py
Normal file
0
backend/sophon/projects/__init__.py
Normal file
18
backend/sophon/projects/admin.py
Normal file
18
backend/sophon/projects/admin.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from sophon.core.admin import SophonAdmin
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.ResearchProject)
|
||||||
|
class ResearchProjectAdmin(SophonAdmin):
|
||||||
|
list_display = (
|
||||||
|
"slug",
|
||||||
|
"name",
|
||||||
|
"group",
|
||||||
|
"visibility",
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = (
|
||||||
|
"slug",
|
||||||
|
)
|
6
backend/sophon/projects/apps.py
Normal file
6
backend/sophon/projects/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectsConfig(AppConfig):
|
||||||
|
name = 'sophon.projects'
|
||||||
|
verbose_name = "Sophon Projects"
|
29
backend/sophon/projects/migrations/0001_initial.py
Normal file
29
backend/sophon/projects/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 3.2 on 2021-08-10 21:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0002_alter_researchgroup_options'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ResearchProject',
|
||||||
|
fields=[
|
||||||
|
('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the project.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')),
|
||||||
|
('name', models.CharField(help_text='The display name of the project.', max_length=512, verbose_name='Name')),
|
||||||
|
('description', models.TextField(blank=True, help_text='A brief description of the project, to be displayed in the overview.', verbose_name='Description')),
|
||||||
|
('visibility', models.CharField(choices=[('PUBLIC', '🌍 Public'), ('INTERNAL', '🏭 Internal'), ('PRIVATE', '🔒 Private')], default='INTERNAL', help_text='A setting specifying who can view the project contents.', max_length=16, verbose_name='Visibility')),
|
||||||
|
('group', models.ForeignKey(help_text='The group this project belongs to.', on_delete=django.db.models.deletion.CASCADE, to='core.researchgroup')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
0
backend/sophon/projects/migrations/__init__.py
Normal file
0
backend/sophon/projects/migrations/__init__.py
Normal file
83
backend/sophon/projects/models.py
Normal file
83
backend/sophon/projects/models.py
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
from django.db import models
|
||||||
|
from sophon.core.models import SophonGroupModel, ResearchGroup
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchProject(SophonGroupModel):
|
||||||
|
"""
|
||||||
|
A :class:`.ResearchProject` is a work which may use zero or more :class:`.DataSource`\\ s to prove or disprove an
|
||||||
|
hypothesis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
slug = models.SlugField(
|
||||||
|
"Slug",
|
||||||
|
help_text="Unique alphanumeric string which identifies the project.",
|
||||||
|
max_length=64,
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
group = models.ForeignKey(
|
||||||
|
ResearchGroup,
|
||||||
|
help_text="The group this project belongs to.",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
"Name",
|
||||||
|
help_text="The display name of the project.",
|
||||||
|
max_length=512,
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.TextField(
|
||||||
|
"Description",
|
||||||
|
help_text="A brief description of the project, to be displayed in the overview.",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
visibility = models.CharField(
|
||||||
|
"Visibility",
|
||||||
|
help_text="A setting specifying who can view the project contents.",
|
||||||
|
choices=[
|
||||||
|
("PUBLIC", "🌍 Public"),
|
||||||
|
("INTERNAL", "🏭 Internal"),
|
||||||
|
("PRIVATE", "🔒 Private"),
|
||||||
|
],
|
||||||
|
default="INTERNAL",
|
||||||
|
max_length=16,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_group(self) -> ResearchGroup:
|
||||||
|
return self.group
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_fields(cls) -> set[str]:
|
||||||
|
return {
|
||||||
|
"slug",
|
||||||
|
"group",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"visibility",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_editable_fields(cls) -> set[str]:
|
||||||
|
return {
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_administrable_fields(cls) -> set[str]:
|
||||||
|
return {
|
||||||
|
"group",
|
||||||
|
"visibility",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_creation_fields(cls) -> set[str]:
|
||||||
|
return {
|
||||||
|
"slug",
|
||||||
|
"group",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"visibility",
|
||||||
|
}
|
3
backend/sophon/projects/tests.py
Normal file
3
backend/sophon/projects/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
13
backend/sophon/projects/urls.py
Normal file
13
backend/sophon/projects/urls.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from django.urls import path, include
|
||||||
|
import rest_framework.routers
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
router = rest_framework.routers.DefaultRouter()
|
||||||
|
router.register("projects", views.ResearchProjectViewSet, basename="research-project")
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
23
backend/sophon/projects/views.py
Normal file
23
backend/sophon/projects/views.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from sophon.core.models import ResearchGroup
|
||||||
|
from sophon.core.views import SophonGroupViewSet
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchProjectViewSet(SophonGroupViewSet):
|
||||||
|
def get_queryset(self):
|
||||||
|
if self.request.user.is_anonymous:
|
||||||
|
return models.ResearchProject.objects.filter(
|
||||||
|
Q(visibility="PUBLIC")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return models.ResearchProject.objects.filter(
|
||||||
|
Q(visibility="PUBLIC") |
|
||||||
|
Q(visibility="INTERNAL") |
|
||||||
|
Q(visibility="PRIVATE", group__members__in=[self.request.user])
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_group_from_serializer(self, serializer) -> ResearchGroup:
|
||||||
|
return serializer.validated_data["group"]
|
|
@ -44,7 +44,8 @@ INSTALLED_APPS = [
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken',
|
'rest_framework.authtoken',
|
||||||
'colorfield',
|
'colorfield',
|
||||||
'sophon.core', # FIXME: Is .apps.CoreConfig not needed?
|
'sophon.core',
|
||||||
|
'sophon.projects',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
@ -149,9 +150,9 @@ REST_FRAMEWORK = {
|
||||||
# Use Django's standard `django.contrib.auth` permissions,
|
# Use Django's standard `django.contrib.auth` permissions,
|
||||||
# or allow read-only access for unauthenticated users.
|
# or allow read-only access for unauthenticated users.
|
||||||
'DEFAULT_PERMISSION_CLASSES': [
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
|
'rest_framework.permissions.AllowAny'
|
||||||
],
|
],
|
||||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
'rest_framework.authentication.BasicAuthentication',
|
'rest_framework.authentication.BasicAuthentication',
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
|
|
|
@ -22,4 +22,5 @@ urlpatterns = [
|
||||||
path('api/auth/token/', CustomObtainAuthToken.as_view()),
|
path('api/auth/token/', CustomObtainAuthToken.as_view()),
|
||||||
path('api/auth/session/', include("rest_framework.urls")),
|
path('api/auth/session/', include("rest_framework.urls")),
|
||||||
path('api/core/', include("sophon.core.urls")),
|
path('api/core/', include("sophon.core.urls")),
|
||||||
|
path('api/projects/', include("sophon.projects.urls")),
|
||||||
]
|
]
|
||||||
|
|
2
backend/tools/create_project.http
Normal file
2
backend/tools/create_project.http
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
POST http://127.0.0.1:30033/api/core/projects/
|
||||||
|
|
|
@ -5,6 +5,7 @@ OPTIONS http://127.0.0.1:30033/api/auth/token/
|
||||||
POST http://127.0.0.1:30033/api/auth/token/
|
POST http://127.0.0.1:30033/api/auth/token/
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
|
# Add here your username and password
|
||||||
{
|
{
|
||||||
"username": "",
|
"username": "",
|
||||||
"password": ""
|
"password": ""
|
Loading…
Reference in a new issue