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="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="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="PyNestedDecoratorsInspection" 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">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
<component name="PythonCompatibilityInspectionAdvertiser">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
</project>
|
44
backend/poetry.lock
generated
44
backend/poetry.lock
generated
|
@ -25,6 +25,17 @@ category = "main"
|
|||
optional = false
|
||||
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]]
|
||||
name = "django"
|
||||
version = "3.2"
|
||||
|
@ -125,6 +136,17 @@ category = "main"
|
|||
optional = false
|
||||
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]]
|
||||
name = "pandas"
|
||||
version = "1.2.4"
|
||||
|
@ -181,6 +203,14 @@ dotenv = ["python-dotenv (>=0.10.4)"]
|
|||
email = ["email-validator (>=1.0.3)"]
|
||||
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]]
|
||||
name = "python-dateutil"
|
||||
version = "2.8.1"
|
||||
|
@ -258,7 +288,7 @@ brotli = ["brotlipy (>=0.6.0)"]
|
|||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.9"
|
||||
content-hash = "5414aa8aa7d0a6610e8c65a8686696ae7d40cdc931c9c2e30b61d5e613a17ef8"
|
||||
content-hash = "d14b706e9bdac9f5a747a783482242a12d2881cc08a6a53b46f3985562b20ed8"
|
||||
|
||||
[metadata.files]
|
||||
asgiref = [
|
||||
|
@ -273,6 +303,10 @@ chardet = [
|
|||
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
|
||||
{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 = [
|
||||
{file = "Django-3.2-py3-none-any.whl", hash = "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927"},
|
||||
{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.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 = [
|
||||
{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"},
|
||||
|
@ -438,6 +476,10 @@ pydantic = [
|
|||
{file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"},
|
||||
{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 = [
|
||||
{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"},
|
||||
|
|
|
@ -15,6 +15,7 @@ pandaSDMX = "^1.4.2"
|
|||
pydantic = "~1.7.3"
|
||||
django-pam = "^2.0.0"
|
||||
django-colorfield = "^0.4.2"
|
||||
deprecation = "^2.1.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
from django.contrib import admin, messages
|
||||
from django.contrib import admin
|
||||
|
||||
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)
|
||||
class ResearchGroupAdmin(CoreAdmin):
|
||||
class ResearchGroupAdmin(SophonAdmin):
|
||||
list_display = (
|
||||
"slug",
|
||||
"name",
|
||||
|
@ -20,137 +20,3 @@ class ResearchGroupAdmin(CoreAdmin):
|
|||
ordering = (
|
||||
"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
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
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()
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DataFlow',
|
||||
name='ResearchGroup',
|
||||
fields=[
|
||||
('surrogate_id', models.IntegerField(help_text='Internal id used by Django to identify this DataFlow.',
|
||||
primary_key=True, serialize=False, verbose_name='Surrogate id')),
|
||||
('id',
|
||||
models.CharField(help_text='Internal string used in SDMX communication to identify the DataFlow.',
|
||||
max_length=64, verbose_name='SDMX id')),
|
||||
('description', models.TextField(blank=True, help_text='Natural language description of the DataFlow.',
|
||||
verbose_name='Description')),
|
||||
('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')),
|
||||
('name', models.CharField(help_text='The displayed 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.', 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 users can join the group.', max_length=16, verbose_name='Access')),
|
||||
('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)),
|
||||
('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)),
|
||||
],
|
||||
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
|
||||
import typing as t
|
||||
"""
|
||||
This module contains the base classes for models in all :mod:`sophon`, and additionally it contains some fundamental models required to have Sophon work
|
||||
properly.
|
||||
"""
|
||||
|
||||
import pandas
|
||||
import pandasdmx
|
||||
import pandasdmx.message
|
||||
from django.contrib.auth.models import User
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import abc
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from colorfield import fields as colorfield_models
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from sophon.core.enums import SophonGroupAccess
|
||||
|
||||
|
||||
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
|
||||
providers.
|
||||
The **abstract** base class for any database model used by Sophon.
|
||||
|
||||
PandaSDMX supports natively multiple data sources, listed
|
||||
`here <https://pandasdmx.readthedocs.io/en/v1.0/sources.html#data-sources>`_ .
|
||||
.. 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.
|
||||
|
||||
They are duplicated in the database to allow for custom sources to be added through the :meth:`pandasdmx.add_source`
|
||||
method.
|
||||
It implements utilities for serialization and authorization.
|
||||
"""
|
||||
|
||||
id = models.CharField(
|
||||
"PandaSDMX id",
|
||||
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.",
|
||||
)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@classmethod
|
||||
def create_from_sources_json(cls, file: t.TextIO):
|
||||
j_sources: list = json.load(file)
|
||||
|
||||
for j_source in j_sources:
|
||||
|
||||
# Flatten supports
|
||||
if supports := j_source.get("supports"):
|
||||
del j_source["supports"]
|
||||
for key, value in supports.items():
|
||||
if key == "structure-specific data":
|
||||
j_source["supports_structurespecific_data"] = value
|
||||
else:
|
||||
j_source[f"supports_{key}"] = value
|
||||
|
||||
cls.objects.update_or_create(
|
||||
id=j_source["id"],
|
||||
defaults={
|
||||
**j_source,
|
||||
"builtin": True,
|
||||
}
|
||||
)
|
||||
|
||||
def to_pandasdmx_source(self) -> pandasdmx.source.Source:
|
||||
@abc.abstractmethod
|
||||
def get_fields(cls) -> set[str]:
|
||||
"""
|
||||
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!
|
||||
:return: The :class:`set` of field names (as :class:`str`) that will be serialized in the ``list`` and ``retrieve`` actions.
|
||||
"""
|
||||
|
||||
log.debug(f"Requesting dataflows of {self!r}...")
|
||||
flows, structs = self.request_flows()
|
||||
raise NotImplementedError()
|
||||
|
||||
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}!")
|
||||
@classmethod
|
||||
def get_view_serializer(cls) -> typing.Type[ModelSerializer]:
|
||||
"""
|
||||
:return: The :class:`.serializers.ModelSerializer` containing this object's fields in _read-only_ mode.
|
||||
"""
|
||||
|
||||
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}")
|
||||
class ViewSerializer(ModelSerializer):
|
||||
class Meta:
|
||||
model = cls
|
||||
fields = list(cls.get_fields())
|
||||
read_only_fields = fields
|
||||
|
||||
def __str__(self):
|
||||
return self.id
|
||||
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
|
||||
|
||||
|
||||
class DataFlow(models.Model):
|
||||
# noinspection PyAbstractClass
|
||||
class SophonGroupModel(SophonModel):
|
||||
"""
|
||||
A :class:`.DataFlow` is a object containing the metadata of a SDMX data set.
|
||||
The **abstract** base class for database objects belonging to a :class:`.ResearchGroup`.
|
||||
|
||||
See `this page <https://ec.europa.eu/eurostat/online-help/redisstat-admin/en/TECH_A_main/>`_ for more details.
|
||||
.. 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.
|
||||
"""
|
||||
|
||||
surrogate_id = models.BigAutoField(
|
||||
"Surrogate id",
|
||||
help_text="Internal id used by Django to identify this DataFlow.",
|
||||
primary_key=True,
|
||||
)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
datasource = models.ForeignKey(
|
||||
DataSource,
|
||||
help_text="The DataSource this object belongs to.",
|
||||
on_delete=models.RESTRICT,
|
||||
)
|
||||
@abc.abstractmethod
|
||||
def get_group(self) -> ResearchGroup:
|
||||
"""
|
||||
:return: The :class:`.ResearchGroup` this objects belongs to.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
sdmx_id = models.CharField(
|
||||
"SDMX id",
|
||||
help_text="Internal string used in SDMX communication to identify the DataFlow.",
|
||||
max_length=64,
|
||||
)
|
||||
@classmethod
|
||||
def get_access_to_edit(cls) -> SophonGroupAccess:
|
||||
"""
|
||||
:return: The minimum required :class:`.SophonGroupAccess` to **Edit** this object.
|
||||
"""
|
||||
return SophonGroupAccess.MEMBER
|
||||
|
||||
description = models.TextField(
|
||||
"Description",
|
||||
help_text="Natural language description of the DataFlow.",
|
||||
blank=True,
|
||||
)
|
||||
def can_edit(self, user: User) -> bool:
|
||||
current = self.get_group().get_access(user)
|
||||
required = self.get_access_to_edit()
|
||||
return current >= required
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.datasource}] {self.sdmx_id}"
|
||||
@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:
|
||||
return self.get_view_serializer()
|
||||
|
||||
|
||||
class ResearchGroup(models.Model):
|
||||
class ResearchGroup(SophonGroupModel):
|
||||
"""
|
||||
A :class:`.ResearchGroup` is a group of users which collectively own :class:`.ResearchProjects`.
|
||||
"""
|
||||
|
||||
slug = models.SlugField(
|
||||
"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,
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
"Name",
|
||||
help_text="The display name of the group.",
|
||||
help_text="The displayed name of the group.",
|
||||
max_length=512,
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
"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,
|
||||
)
|
||||
|
||||
owner = models.ForeignKey(
|
||||
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,
|
||||
)
|
||||
|
||||
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",
|
||||
help_text="A setting specifying how can users join this group.",
|
||||
help_text="A setting specifying how users can join the group.",
|
||||
choices=[
|
||||
("MANUAL", "⛔️ Collaborators must be added manually"),
|
||||
# ("REQUEST", "✉️ Users can request an invite from the group owner"),
|
||||
("OPEN", "❇️ Users can join the group freely"),
|
||||
],
|
||||
default="MANUAL",
|
||||
max_length=16,
|
||||
)
|
||||
|
||||
def can_be_edited_by(self, user) -> bool:
|
||||
def get_group(self) -> ResearchGroup:
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def get_fields(cls) -> set[str]:
|
||||
return {
|
||||
"slug",
|
||||
"name",
|
||||
"description",
|
||||
"owner",
|
||||
"members",
|
||||
"access",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_editable_fields(cls) -> set[str]:
|
||||
return {
|
||||
"name",
|
||||
"description",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_administrable_fields(cls) -> set[str]:
|
||||
return {
|
||||
"members",
|
||||
"access",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_creation_fields(cls) -> set[str]:
|
||||
return {
|
||||
"slug",
|
||||
"name",
|
||||
"description",
|
||||
"members",
|
||||
"access",
|
||||
}
|
||||
|
||||
def get_access(self, user) -> SophonGroupAccess:
|
||||
"""
|
||||
Get the :class:`SophonGroupAccess` that an user has on this group.
|
||||
"""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
elif user in self.members:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def can_be_administrated_by(self, user) -> bool:
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
elif user in self.owner:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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:
|
||||
return True
|
||||
|
||||
return SophonGroupAccess.SUPERUSER
|
||||
elif user == self.owner:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
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
|
||||
|
||||
return SophonGroupAccess.OWNER
|
||||
elif user in self.members.all():
|
||||
return SophonGroupAccess.MEMBER
|
||||
elif not user.is_anonymous:
|
||||
return SophonGroupAccess.REGISTERED
|
||||
else:
|
||||
raise ValueError(f"Unknown visibility value: {self.visibility}")
|
||||
|
||||
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
|
||||
return SophonGroupAccess.NONE
|
||||
|
||||
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 . import models
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
|
||||
class DataSourceSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for :class:`.models.DataSource` .
|
||||
"""
|
||||
class NoneSerializer(Serializer):
|
||||
def update(self, instance, validated_data):
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = models.DataSource
|
||||
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",
|
||||
)
|
||||
def create(self, validated_data):
|
||||
return None
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
import rest_framework.routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("datasources", views.DataSourceViewSet)
|
||||
router.register("dataflows", views.DataFlowViewSet)
|
||||
router.register("projects", views.ResearchProjectViewSet)
|
||||
router.register("tags", views.ResearchTagViewSet)
|
||||
router.register("groups", views.ResearchGroupViewSet)
|
||||
|
||||
router = rest_framework.routers.DefaultRouter()
|
||||
router.register("groups", views.ResearchGroupViewSet, basename="research-group")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
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 permissions as custom_permissions
|
||||
|
||||
log = getLogger(__name__)
|
||||
from . import models
|
||||
from . import permissions
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ResearchProjectViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.ResearchProject.objects.all()
|
||||
class HTTPException(Exception):
|
||||
"""
|
||||
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
|
||||
def permission_classes(self):
|
||||
return {
|
||||
"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]
|
||||
return self.get_permission_classes()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
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.
|
||||
"""
|
||||
|
||||
queryset = models.DataFlow.objects.all()
|
||||
serializer_class = serializers.DataFlowSerializer
|
||||
permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
|
||||
|
||||
@decorators.action(methods=["get"], detail=False)
|
||||
def search(self, request: r.Request, *args, **kwargs):
|
||||
# noinspection PyMethodMayBeStatic
|
||||
def get_permission_classes(self) -> t.Collection[t.Type[permissions.BasePermission]]:
|
||||
"""
|
||||
Use Django and PostgreSQL's full text search capabilities to find DataFlows containing certain words in the
|
||||
description.
|
||||
The "method" version of the :attr:`~rest_framework.viewsets.ModelViewSet.permission_classes` property.
|
||||
|
||||
:return: A collection of permission classes.
|
||||
"""
|
||||
return permissions.AllowAny,
|
||||
|
||||
log.debug("Searching DataFlows...")
|
||||
if not (query := request.query_params.get("q")):
|
||||
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.
|
||||
"""
|
||||
|
||||
queryset = models.DataSource.objects.all()
|
||||
serializer_class = serializers.DataSourceSerializer
|
||||
permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
|
||||
|
||||
@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`\\ .
|
||||
# noinspection PyMethodMayBeStatic
|
||||
def hook_create(self, serializer) -> dict[str, t.Any]:
|
||||
"""
|
||||
Hook called on ``create`` 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 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 {}
|
||||
|
||||
@deprecation.deprecated(details="Use `.hook_create()` instead.")
|
||||
def perform_create(self, serializer):
|
||||
"""
|
||||
.. 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.")
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer: Serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
log.debug(f"Getting DataSource from the database...")
|
||||
db_datasource: models.DataSource = self.get_object()
|
||||
try:
|
||||
db_datasource.sync_flows()
|
||||
except NotImplementedError:
|
||||
return response.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)
|
||||
hook = self.hook_create(serializer)
|
||||
except HTTPException as e:
|
||||
return e.as_response()
|
||||
|
||||
return response.Response({
|
||||
"success": True,
|
||||
})
|
||||
serializer.save(**hook)
|
||||
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.authtoken',
|
||||
'colorfield',
|
||||
'sophon.core', # FIXME: Is .apps.CoreConfig not needed?
|
||||
'sophon.core',
|
||||
'sophon.projects',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
@ -149,9 +150,9 @@ REST_FRAMEWORK = {
|
|||
# Use Django's standard `django.contrib.auth` permissions,
|
||||
# or allow read-only access for unauthenticated users.
|
||||
'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': [
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
|
|
|
@ -22,4 +22,5 @@ urlpatterns = [
|
|||
path('api/auth/token/', CustomObtainAuthToken.as_view()),
|
||||
path('api/auth/session/', include("rest_framework.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/
|
||||
Content-Type: application/json
|
||||
|
||||
# Add here your username and password
|
||||
{
|
||||
"username": "",
|
||||
"password": ""
|
Loading…
Reference in a new issue