diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 9e2f7cd..68cfe01 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -20,6 +20,7 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index dd29b11..031a0fe 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -6,4 +6,7 @@
+
+
+
\ No newline at end of file
diff --git a/backend/poetry.lock b/backend/poetry.lock
index 3cedeb1..a1c59e9 100644
--- a/backend/poetry.lock
+++ b/backend/poetry.lock
@@ -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"},
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index f906616..985c2ed 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -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]
diff --git a/backend/sophon/core/admin.py b/backend/sophon/core/admin.py
index 9abddbf..df42080 100644
--- a/backend/sophon/core/admin.py
+++ b/backend/sophon/core/admin.py
@@ -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",
- )
diff --git a/backend/sophon/core/enums.py b/backend/sophon/core/enums.py
new file mode 100644
index 0000000..0de9763
--- /dev/null
+++ b/backend/sophon/core/enums.py
@@ -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
diff --git a/backend/sophon/core/migrations/0001_initial.py b/backend/sophon/core/migrations/0001_initial.py
index bb7327a..4e36de9 100644
--- a/backend/sophon/core/migrations/0001_initial.py
+++ b/backend/sophon/core/migrations/0001_initial.py
@@ -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 `_ 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 AgencyScheme or not.',
- verbose_name='Supports AgencyScheme')),
- ('supports_categoryscheme', models.BooleanField(default=True,
- help_text='Whether the data source supports CategoryScheme or not.',
- verbose_name='Supports CategoryScheme')),
- ('supports_codelist', models.BooleanField(default=True,
- help_text='Whether the data source supports CodeList or not.',
- verbose_name='Supports CodeList')),
- ('supports_conceptscheme', models.BooleanField(default=True,
- help_text='Whether the data source supports ConceptScheme or not.',
- verbose_name='Supports ConceptScheme')),
- ('supports_data', models.BooleanField(default=True,
- help_text='Whether the data source supports DataSet or not.',
- verbose_name='Supports DataSet')),
- ('supports_dataflow', models.BooleanField(default=True,
- help_text='Whether the data source supports DataflowDefinition or not.',
- verbose_name='Supports DataflowDefinition')),
- ('supports_datastructure', models.BooleanField(default=True,
- help_text='Whether the data source supports CategoryScheme or not.',
- verbose_name='Supports DataStructureDefinition')),
- ('supports_provisionagreement', models.BooleanField(default=True,
- help_text='Whether the data source supports CategoryScheme or not.',
- verbose_name='Supports ProvisionAgreement')),
- ('supports_preview', models.BooleanField(default=False,
- help_text='Whether the data source supports previews of data or not.',
- verbose_name='Supports previews')),
- ('supports_structurespecific_data', models.BooleanField(default=False,
- help_text='Whether the data source returns structure-specific data messages 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)
]
diff --git a/backend/sophon/core/migrations/0001_sources.json b/backend/sophon/core/migrations/0001_sources.json
deleted file mode 100644
index 65d7f47..0000000
--- a/backend/sophon/core/migrations/0001_sources.json
+++ /dev/null
@@ -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"
- }
-]
diff --git a/backend/sophon/core/migrations/0002_alter_researchgroup_options.py b/backend/sophon/core/migrations/0002_alter_researchgroup_options.py
new file mode 100644
index 0000000..167d714
--- /dev/null
+++ b/backend/sophon/core/migrations/0002_alter_researchgroup_options.py
@@ -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={},
+ ),
+ ]
diff --git a/backend/sophon/core/migrations/0002_auto_20210415_1453.py b/backend/sophon/core/migrations/0002_auto_20210415_1453.py
deleted file mode 100644
index dd88d9f..0000000
--- a/backend/sophon/core/migrations/0002_auto_20210415_1453.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/backend/sophon/core/migrations/0003_rename_id_dataflow_sdmx_id.py b/backend/sophon/core/migrations/0003_rename_id_dataflow_sdmx_id.py
deleted file mode 100644
index 40da5be..0000000
--- a/backend/sophon/core/migrations/0003_rename_id_dataflow_sdmx_id.py
+++ /dev/null
@@ -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',
- ),
- ]
diff --git a/backend/sophon/core/migrations/0004_alter_dataflow_surrogate_id.py b/backend/sophon/core/migrations/0004_alter_dataflow_surrogate_id.py
deleted file mode 100644
index d23238a..0000000
--- a/backend/sophon/core/migrations/0004_alter_dataflow_surrogate_id.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/backend/sophon/core/migrations/0005_auto_20210806_1506.py b/backend/sophon/core/migrations/0005_auto_20210806_1506.py
deleted file mode 100644
index b7a1960..0000000
--- a/backend/sophon/core/migrations/0005_auto_20210806_1506.py
+++ /dev/null
@@ -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',
- ),
- ]
diff --git a/backend/sophon/core/migrations/0006_auto_20210806_1918.py b/backend/sophon/core/migrations/0006_auto_20210806_1918.py
deleted file mode 100644
index d775e23..0000000
--- a/backend/sophon/core/migrations/0006_auto_20210806_1918.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/backend/sophon/core/models.py b/backend/sophon/core/models.py
index 3955c9c..124db2d 100644
--- a/backend/sophon/core/models.py
+++ b/backend/sophon/core/models.py
@@ -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 `_ .
+ .. 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 '
- ''
- 'AgencyScheme '
- ' or not.',
- default=True,
- )
-
- supports_categoryscheme = models.BooleanField(
- "Supports CategoryScheme",
- help_text='Whether the data source supports '
- ''
- 'CategoryScheme '
- ' or not.',
- default=True,
- )
-
- supports_codelist = models.BooleanField(
- "Supports CodeList",
- help_text='Whether the data source supports '
- ''
- 'CodeList '
- ' or not.',
- default=True,
- )
-
- supports_conceptscheme = models.BooleanField(
- "Supports ConceptScheme",
- help_text='Whether the data source supports '
- ''
- 'ConceptScheme '
- ' or not.',
- default=True,
- )
-
- supports_data = models.BooleanField(
- "Supports DataSet",
- help_text='Whether the data source supports '
- ''
- 'DataSet '
- ' or not.',
- default=True,
- )
-
- supports_dataflow = models.BooleanField(
- "Supports DataflowDefinition",
- help_text='Whether the data source supports '
- ''
- 'DataflowDefinition '
- ' or not.',
- default=True,
- )
-
- supports_datastructure = models.BooleanField(
- "Supports DataStructureDefinition",
- help_text='Whether the data source supports '
- ''
- 'CategoryScheme '
- ' or not.',
- default=True,
- )
-
- supports_provisionagreement = models.BooleanField(
- "Supports ProvisionAgreement",
- help_text='Whether the data source supports '
- ''
- 'CategoryScheme '
- ' or not.',
- default=True,
- )
-
- supports_preview = models.BooleanField(
- "Supports previews",
- help_text='Whether the data source supports '
- ''
- 'previews of data '
- ' or not.',
- default=False,
- )
-
- supports_structurespecific_data = models.BooleanField(
- "Supports structure-specific data messages",
- help_text='Whether the data source returns '
- ''
- 'structure-specific data messages '
- ' 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 `_ 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}"
diff --git a/backend/sophon/core/permissions.py b/backend/sophon/core/permissions.py
new file mode 100644
index 0000000..d2a87cc
--- /dev/null
+++ b/backend/sophon/core/permissions.py
@@ -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",
+)
diff --git a/backend/sophon/core/serializers.py b/backend/sophon/core/serializers.py
index a6034d8..8f8347c 100644
--- a/backend/sophon/core/serializers.py
+++ b/backend/sophon/core/serializers.py
@@ -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
diff --git a/backend/sophon/core/urls.py b/backend/sophon/core/urls.py
index 72e8283..63b764d 100644
--- a/backend/sophon/core/urls.py
+++ b/backend/sophon/core/urls.py
@@ -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)),
diff --git a/backend/sophon/core/views.py b/backend/sophon/core/views.py
index 1fcaee0..369410c 100644
--- a/backend/sophon/core/views.py
+++ b/backend/sophon/core/views.py
@@ -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,
diff --git a/backend/sophon/permissions.py b/backend/sophon/permissions.py
deleted file mode 100644
index c2bce86..0000000
--- a/backend/sophon/permissions.py
+++ /dev/null
@@ -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)
diff --git a/backend/sophon/projects/__init__.py b/backend/sophon/projects/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/sophon/projects/admin.py b/backend/sophon/projects/admin.py
new file mode 100644
index 0000000..26b04d2
--- /dev/null
+++ b/backend/sophon/projects/admin.py
@@ -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",
+ )
diff --git a/backend/sophon/projects/apps.py b/backend/sophon/projects/apps.py
new file mode 100644
index 0000000..367a3ef
--- /dev/null
+++ b/backend/sophon/projects/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ProjectsConfig(AppConfig):
+ name = 'sophon.projects'
+ verbose_name = "Sophon Projects"
diff --git a/backend/sophon/projects/migrations/0001_initial.py b/backend/sophon/projects/migrations/0001_initial.py
new file mode 100644
index 0000000..c92938e
--- /dev/null
+++ b/backend/sophon/projects/migrations/0001_initial.py
@@ -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,
+ },
+ ),
+ ]
diff --git a/backend/sophon/projects/migrations/__init__.py b/backend/sophon/projects/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/sophon/projects/models.py b/backend/sophon/projects/models.py
new file mode 100644
index 0000000..d1af4be
--- /dev/null
+++ b/backend/sophon/projects/models.py
@@ -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",
+ }
diff --git a/backend/sophon/projects/tests.py b/backend/sophon/projects/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/backend/sophon/projects/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/backend/sophon/projects/urls.py b/backend/sophon/projects/urls.py
new file mode 100644
index 0000000..2f01aaf
--- /dev/null
+++ b/backend/sophon/projects/urls.py
@@ -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)),
+]
diff --git a/backend/sophon/projects/views.py b/backend/sophon/projects/views.py
new file mode 100644
index 0000000..ba8b5d3
--- /dev/null
+++ b/backend/sophon/projects/views.py
@@ -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"]
diff --git a/backend/sophon/settings.py b/backend/sophon/settings.py
index 4c315c2..17e5264 100644
--- a/backend/sophon/settings.py
+++ b/backend/sophon/settings.py
@@ -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',
diff --git a/backend/sophon/urls.py b/backend/sophon/urls.py
index 8c9e72c..a65bf08 100644
--- a/backend/sophon/urls.py
+++ b/backend/sophon/urls.py
@@ -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")),
]
diff --git a/backend/tools/create_project.http b/backend/tools/create_project.http
new file mode 100644
index 0000000..2671929
--- /dev/null
+++ b/backend/tools/create_project.http
@@ -0,0 +1,2 @@
+POST http://127.0.0.1:30033/api/core/projects/
+
diff --git a/backend/get_api_token.http b/backend/tools/get_api_token.http
similarity index 82%
rename from backend/get_api_token.http
rename to backend/tools/get_api_token.http
index 250c6ed..ed80bc8 100644
--- a/backend/get_api_token.http
+++ b/backend/tools/get_api_token.http
@@ -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": ""