From b9d3fe94fe28785772c90ab970d69551f992cab3 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Wed, 11 Aug 2021 00:59:39 +0200 Subject: [PATCH] Merge `permissions-v3` (#64) --- .idea/inspectionProfiles/Project_Default.xml | 1 + .idea/misc.xml | 3 + backend/poetry.lock | 44 +- backend/pyproject.toml | 1 + backend/sophon/core/admin.py | 142 +--- backend/sophon/core/enums.py | 22 + .../sophon/core/migrations/0001_initial.py | 123 +-- .../sophon/core/migrations/0001_sources.json | 194 ----- .../0002_alter_researchgroup_options.py | 17 + .../migrations/0002_auto_20210415_1453.py | 37 - .../0003_rename_id_dataflow_sdmx_id.py | 17 - .../0004_alter_dataflow_surrogate_id.py | 18 - .../migrations/0005_auto_20210806_1506.py | 41 - .../migrations/0006_auto_20210806_1918.py | 32 - backend/sophon/core/models.py | 727 ++++++------------ backend/sophon/core/permissions.py | 32 + backend/sophon/core/serializers.py | 237 +----- backend/sophon/core/urls.py | 12 +- backend/sophon/core/views.py | 388 ++++++---- backend/sophon/permissions.py | 20 - backend/sophon/projects/__init__.py | 0 backend/sophon/projects/admin.py | 18 + backend/sophon/projects/apps.py | 6 + .../projects/migrations/0001_initial.py | 29 + .../sophon/projects/migrations/__init__.py | 0 backend/sophon/projects/models.py | 83 ++ backend/sophon/projects/tests.py | 3 + backend/sophon/projects/urls.py | 13 + backend/sophon/projects/views.py | 23 + backend/sophon/settings.py | 7 +- backend/sophon/urls.py | 1 + backend/tools/create_project.http | 2 + backend/{ => tools}/get_api_token.http | 1 + 33 files changed, 817 insertions(+), 1477 deletions(-) create mode 100644 backend/sophon/core/enums.py delete mode 100644 backend/sophon/core/migrations/0001_sources.json create mode 100644 backend/sophon/core/migrations/0002_alter_researchgroup_options.py delete mode 100644 backend/sophon/core/migrations/0002_auto_20210415_1453.py delete mode 100644 backend/sophon/core/migrations/0003_rename_id_dataflow_sdmx_id.py delete mode 100644 backend/sophon/core/migrations/0004_alter_dataflow_surrogate_id.py delete mode 100644 backend/sophon/core/migrations/0005_auto_20210806_1506.py delete mode 100644 backend/sophon/core/migrations/0006_auto_20210806_1918.py create mode 100644 backend/sophon/core/permissions.py delete mode 100644 backend/sophon/permissions.py create mode 100644 backend/sophon/projects/__init__.py create mode 100644 backend/sophon/projects/admin.py create mode 100644 backend/sophon/projects/apps.py create mode 100644 backend/sophon/projects/migrations/0001_initial.py create mode 100644 backend/sophon/projects/migrations/__init__.py create mode 100644 backend/sophon/projects/models.py create mode 100644 backend/sophon/projects/tests.py create mode 100644 backend/sophon/projects/urls.py create mode 100644 backend/sophon/projects/views.py create mode 100644 backend/tools/create_project.http rename backend/{ => tools}/get_api_token.http (82%) 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": ""