1
Fork 0
mirror of https://github.com/Steffo99/sophon.git synced 2024-12-22 23:04:21 +00:00

Merge permissions-v3 (#64)

This commit is contained in:
Steffo 2021-08-11 00:59:39 +02:00 committed by GitHub
parent 09d2452080
commit c74761ef1b
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 817 additions and 1477 deletions

View file

@ -20,6 +20,7 @@
<inspection_tool class="PyInconsistentIndentationInspection" enabled="true" level="ERROR" enabled_by_default="true" /> <inspection_tool class="PyInconsistentIndentationInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PyInterpreterInspection" enabled="true" level="ERROR" enabled_by_default="true" /> <inspection_tool class="PyInterpreterInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PyMandatoryEncodingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" /> <inspection_tool class="PyMandatoryEncodingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="PyMethodParametersInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PyMissingTypeHintsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" /> <inspection_tool class="PyMissingTypeHintsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="PyNestedDecoratorsInspection" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="PyNestedDecoratorsInspection" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyNoneFunctionAssignmentInspection" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="PyNoneFunctionAssignmentInspection" enabled="true" level="WARNING" enabled_by_default="true" />

View file

@ -6,4 +6,7 @@
<component name="ProjectRootManager" version="2" languageLevel="JDK_15" project-jdk-name="Poetry (backend)" project-jdk-type="Python SDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_15" project-jdk-name="Poetry (backend)" project-jdk-type="Python SDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
</component> </component>
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>
</project> </project>

44
backend/poetry.lock generated
View file

@ -25,6 +25,17 @@ category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "deprecation"
version = "2.1.0"
description = "A library to handle automated deprecations"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
packaging = "*"
[[package]] [[package]]
name = "django" name = "django"
version = "3.2" version = "3.2"
@ -125,6 +136,17 @@ category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[[package]]
name = "packaging"
version = "21.0"
description = "Core utilities for Python packages"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2"
[[package]] [[package]]
name = "pandas" name = "pandas"
version = "1.2.4" version = "1.2.4"
@ -181,6 +203,14 @@ dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"] email = ["email-validator (>=1.0.3)"]
typing_extensions = ["typing-extensions (>=3.7.2)"] typing_extensions = ["typing-extensions (>=3.7.2)"]
[[package]]
name = "pyparsing"
version = "2.4.7"
description = "Python parsing module"
category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.8.1" version = "2.8.1"
@ -258,7 +288,7 @@ brotli = ["brotlipy (>=0.6.0)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "5414aa8aa7d0a6610e8c65a8686696ae7d40cdc931c9c2e30b61d5e613a17ef8" content-hash = "d14b706e9bdac9f5a747a783482242a12d2881cc08a6a53b46f3985562b20ed8"
[metadata.files] [metadata.files]
asgiref = [ asgiref = [
@ -273,6 +303,10 @@ chardet = [
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
] ]
deprecation = [
{file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"},
{file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"},
]
django = [ django = [
{file = "Django-3.2-py3-none-any.whl", hash = "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927"}, {file = "Django-3.2-py3-none-any.whl", hash = "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927"},
{file = "Django-3.2.tar.gz", hash = "sha256:21f0f9643722675976004eb683c55d33c05486f94506672df3d6a141546f389d"}, {file = "Django-3.2.tar.gz", hash = "sha256:21f0f9643722675976004eb683c55d33c05486f94506672df3d6a141546f389d"},
@ -375,6 +409,10 @@ numpy = [
{file = "numpy-1.20.2-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:97ce8b8ace7d3b9288d88177e66ee75480fb79b9cf745e91ecfe65d91a856042"}, {file = "numpy-1.20.2-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:97ce8b8ace7d3b9288d88177e66ee75480fb79b9cf745e91ecfe65d91a856042"},
{file = "numpy-1.20.2.zip", hash = "sha256:878922bf5ad7550aa044aa9301d417e2d3ae50f0f577de92051d739ac6096cee"}, {file = "numpy-1.20.2.zip", hash = "sha256:878922bf5ad7550aa044aa9301d417e2d3ae50f0f577de92051d739ac6096cee"},
] ]
packaging = [
{file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
{file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
]
pandas = [ pandas = [
{file = "pandas-1.2.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c601c6fdebc729df4438ec1f62275d6136a0dd14d332fc0e8ce3f7d2aadb4dd6"}, {file = "pandas-1.2.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c601c6fdebc729df4438ec1f62275d6136a0dd14d332fc0e8ce3f7d2aadb4dd6"},
{file = "pandas-1.2.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8d4c74177c26aadcfb4fd1de6c1c43c2bf822b3e0fc7a9b409eeaf84b3e92aaa"}, {file = "pandas-1.2.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8d4c74177c26aadcfb4fd1de6c1c43c2bf822b3e0fc7a9b409eeaf84b3e92aaa"},
@ -438,6 +476,10 @@ pydantic = [
{file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"}, {file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"},
{file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"}, {file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"},
] ]
pyparsing = [
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
]
python-dateutil = [ python-dateutil = [
{file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
{file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},

View file

@ -15,6 +15,7 @@ pandaSDMX = "^1.4.2"
pydantic = "~1.7.3" pydantic = "~1.7.3"
django-pam = "^2.0.0" django-pam = "^2.0.0"
django-colorfield = "^0.4.2" django-colorfield = "^0.4.2"
deprecation = "^2.1.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]

View file

@ -1,16 +1,16 @@
from django.contrib import admin, messages from django.contrib import admin
from . import models from . import models
class CoreAdmin(admin.ModelAdmin): class SophonAdmin(admin.ModelAdmin):
""" """
:class:`django.contrib.admin.ModelAdmin` class from which all other admin classes inherit. Base :class:`django.contrib.admin.ModelAdmin` class from which all other admin classes inherit.
""" """
@admin.register(models.ResearchGroup) @admin.register(models.ResearchGroup)
class ResearchGroupAdmin(CoreAdmin): class ResearchGroupAdmin(SophonAdmin):
list_display = ( list_display = (
"slug", "slug",
"name", "name",
@ -20,137 +20,3 @@ class ResearchGroupAdmin(CoreAdmin):
ordering = ( ordering = (
"slug", "slug",
) )
@admin.register(models.ResearchTag)
class ResearchTagAdmin(CoreAdmin):
list_display = (
"slug",
"name",
"color",
"owner",
)
ordering = (
"slug",
)
@admin.register(models.ResearchProject)
class ResearchProjectAdmin(CoreAdmin):
list_display = (
"group",
"slug",
"name",
"visibility",
)
ordering = (
"slug",
)
@admin.action(description="Sync DataFlows")
def sync_flows_admin(modeladmin, request, queryset):
for datasource in queryset:
datasource: models.DataSource
try:
datasource.sync_flows()
except NotImplementedError:
modeladmin.message_user(
request,
f"Skipped {datasource}: Syncing DataFlows is not supported on this DataSource.",
level=messages.ERROR
)
except Exception as exc:
modeladmin.message_user(
request,
f"Skipped {datasource}: {exc}",
level=messages.ERROR
)
else:
modeladmin.log_change(request, datasource, "Sync DataFlows")
@admin.register(models.DataSource)
class DataSourceAdmin(CoreAdmin):
list_display = (
"id",
"name",
"data_content_type",
"last_sync",
)
ordering = (
"last_sync",
)
actions = (
sync_flows_admin,
)
fieldsets = (
(
None, {
"fields": (
"id",
"name",
"description",
)
}
),
(
"URLs", {
"fields": (
"url",
"documentation",
)
}
),
(
"API configuration", {
"fields": (
"data_content_type",
"headers",
"resources",
)
}
),
(
"Features supported", {
"fields": (
"supports_agencyscheme",
"supports_categoryscheme",
"supports_codelist",
"supports_conceptscheme",
"supports_data",
"supports_dataflow",
"supports_datastructure",
"supports_provisionagreement",
"supports_preview",
"supports_structurespecific_data",
)
}
),
(
"Syncronization", {
"fields": (
"last_sync",
)
}
)
)
@admin.register(models.DataFlow)
class DataFlowAdmin(CoreAdmin):
list_display = (
"datasource",
"sdmx_id",
"description",
)
ordering = (
"datasource",
"sdmx_id",
)

View file

@ -0,0 +1,22 @@
import enum
class SophonGroupAccess(enum.IntEnum):
"""
The level of access an user has in a group.
From the highest to the lowest:
- Instance superuser
- Group owner
- Group member
- Instance user
- Anonymous user
Since access levels are instance of an :class:`~enum.IntEnum`, they can be compared with ``==``, ``!=``, ``>``, ``<``, ``>=`` and ``<=``.
"""
SUPERUSER = 200
OWNER = 100
MEMBER = 50
REGISTERED = 10
NONE = 0

View file

@ -1,123 +1,32 @@
# Generated by Django 3.2 on 2021-04-08 14:36 # Generated by Django 3.2 on 2021-08-10 21:20
import importlib.resources from django.conf import settings
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
from .. import models as core_models
def create_builtin_sources(apps, schema_editor):
"""
Create in the database the sources that are already built-in in :mod:`pandasdmx`.
This function is called when performing the migration: see
`this page <https://docs.djangoproject.com/en/3.1/topics/migrations/#data-migrations>`_ for details on how this
works!
"""
file = importlib.resources.open_text("sophon.core.migrations", "0001_sources.json")
core_models.DataSource.create_from_sources_json(file=file)
file.close()
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='DataFlow', name='ResearchGroup',
fields=[ fields=[
('surrogate_id', models.IntegerField(help_text='Internal id used by Django to identify this DataFlow.', ('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the group in the Sophon instance.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')),
primary_key=True, serialize=False, verbose_name='Surrogate id')), ('name', models.CharField(help_text='The displayed name of the group.', max_length=512, verbose_name='Name')),
('id', ('description', models.TextField(blank=True, help_text='A brief description of what the group is about.', verbose_name='Description')),
models.CharField(help_text='Internal string used in SDMX communication to identify the DataFlow.', ('access', models.CharField(choices=[('MANUAL', '⛔️ Collaborators must be added manually'), ('OPEN', '❇️ Users can join the group freely')], default='MANUAL', help_text='A setting specifying how users can join the group.', max_length=16, verbose_name='Access')),
max_length=64, verbose_name='SDMX id')), ('members', models.ManyToManyField(blank=True, help_text='The users who belong to this group.', related_name='is_a_member_of', to=settings.AUTH_USER_MODEL)),
('description', models.TextField(blank=True, help_text='Natural language description of the DataFlow.', ('owner', models.ForeignKey(help_text='The user who created the group, who is automatically a member.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
verbose_name='Description')),
], ],
options={
'verbose_name': 'Research Group',
'verbose_name_plural': 'Research Groups',
},
), ),
migrations.CreateModel(
name='DataSource',
fields=[
('id',
models.CharField(help_text='Internal id used by PandaSDMX to reference the source.', max_length=16,
primary_key=True, serialize=False, verbose_name='PandaSDMX id')),
('name', models.CharField(help_text='Full length name of the data source.', max_length=512,
verbose_name='Name')),
('description', models.TextField(blank=True, help_text='Long description of the data source.',
verbose_name='Description')),
('url', models.URLField(help_text='The base URL of the SDMX endpoint of the data source.',
verbose_name='API URL')),
('documentation', models.URLField(help_text='Documentation URL of the data source.', null=True,
verbose_name='Documentation URL')),
('data_content_type', models.CharField(choices=[('JSON', 'JSON'), ('XML', 'XML')], default='XML',
help_text='The format in which the API returns its data.',
max_length=16, verbose_name='API type')),
('headers',
models.JSONField(default=dict, help_text='HTTP headers to attach to every request, as a JSON object.',
verbose_name='HTTP Headers')),
('resources', models.JSONField(default=dict, help_text='Unknown and undocumented JSON object.',
verbose_name='Resources')),
('supports_agencyscheme', models.BooleanField(default=True,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.AgencyScheme">AgencyScheme </a> or not.',
verbose_name='Supports AgencyScheme')),
('supports_categoryscheme', models.BooleanField(default=True,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.CategoryScheme">CategoryScheme </a> or not.',
verbose_name='Supports CategoryScheme')),
('supports_codelist', models.BooleanField(default=True,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.CodeList">CodeList </a> or not.',
verbose_name='Supports CodeList')),
('supports_conceptscheme', models.BooleanField(default=True,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.ConceptScheme">ConceptScheme </a> or not.',
verbose_name='Supports ConceptScheme')),
('supports_data', models.BooleanField(default=True,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataSet">DataSet </a> or not.',
verbose_name='Supports DataSet')),
('supports_dataflow', models.BooleanField(default=True,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataflowDefinition">DataflowDefinition </a> or not.',
verbose_name='Supports DataflowDefinition')),
('supports_datastructure', models.BooleanField(default=True,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataStructureDefinition">CategoryScheme </a> or not.',
verbose_name='Supports DataStructureDefinition')),
('supports_provisionagreement', models.BooleanField(default=True,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.ProvisionAgreement">CategoryScheme </a> or not.',
verbose_name='Supports ProvisionAgreement')),
('supports_preview', models.BooleanField(default=False,
help_text='Whether the data source supports <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.Request.preview_data">previews of data </a> or not.',
verbose_name='Supports previews')),
('supports_structurespecific_data', models.BooleanField(default=False,
help_text='Whether the data source returns <a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.source.Source">structure-specific data messages </a> or not.',
verbose_name='Supports structure-specific data messages')),
('builtin', models.BooleanField(help_text='Whether the source is built-in in PandaSDMX or not.',
verbose_name='Builtin')),
('last_sync', models.DateTimeField(
help_text='The datetime at which the data flows of this source were last syncronized.', null=True,
verbose_name='Last updated')),
],
),
migrations.CreateModel(
name='Project',
fields=[
('slug',
models.SlugField(help_text='Unique alphanumeric string which identifies the project.', max_length=64,
primary_key=True, serialize=False, verbose_name='Slug')),
('name',
models.CharField(help_text='The display name of the project.', max_length=512, verbose_name='Name')),
('description', models.TextField(blank=True,
help_text='A brief description of the project, to be displayed inthe overview.',
verbose_name='Description')),
('flows', models.ManyToManyField(blank=True, help_text='The DataFlows used in this project.',
related_name='used_in', to='core.DataFlow')),
],
),
migrations.AddField(
model_name='dataflow',
name='datasource',
field=models.ForeignKey(help_text='The DataSource this object belongs to.',
on_delete=django.db.models.deletion.RESTRICT, to='core.datasource'),
),
migrations.RunPython(create_builtin_sources)
] ]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,544 +1,321 @@
import json """
import logging This module contains the base classes for models in all :mod:`sophon`, and additionally it contains some fundamental models required to have Sophon work
import typing as t properly.
"""
import pandas from __future__ import annotations
import pandasdmx import typing
import pandasdmx.message import abc
from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.utils import timezone from django.contrib.auth.models import User
from colorfield import fields as colorfield_models from rest_framework.serializers import ModelSerializer
from sophon.core.enums import SophonGroupAccess
log = logging.getLogger(__name__)
class DataSource(models.Model): class SophonModel(models.Model):
""" """
A :class:`.DataSource` is a web service which provides access to statistical information sourced by multiple data The **abstract** base class for any database model used by Sophon.
providers.
PandaSDMX supports natively multiple data sources, listed .. warning:: Since its metaclass is :class:`django.db.ModelBase`, the :class:`abc.ABCMeta` metaclass can't be applied, so method implementation cannot be
`here <https://pandasdmx.readthedocs.io/en/v1.0/sources.html#data-sources>`_ . checked at runtime.
They are duplicated in the database to allow for custom sources to be added through the :meth:`pandasdmx.add_source` It implements utilities for serialization and authorization.
method.
""" """
id = models.CharField( class Meta:
"PandaSDMX id", abstract = True
help_text="Internal id used by PandaSDMX to reference the source.",
max_length=16,
primary_key=True,
)
name = models.CharField(
"Name",
help_text="Full length name of the data source.",
max_length=512,
)
description = models.TextField(
"Description",
help_text="Long description of the data source.",
blank=True,
)
url = models.URLField(
"API URL",
help_text="The base URL of the SDMX endpoint of the data source."
)
documentation = models.URLField(
"Documentation URL",
help_text="Documentation URL of the data source.",
null=True,
)
data_content_type = models.CharField(
"API type",
help_text="The format in which the API returns its data.",
choices=[
("JSON", "JSON"),
("XML", "XML"),
],
default="XML",
max_length=16,
)
headers = models.JSONField(
"HTTP Headers",
help_text="HTTP headers to attach to every request, as a JSON object.",
default=dict,
)
resources = models.JSONField(
"Resources",
help_text="Unknown and undocumented JSON object.",
default=dict,
)
supports_agencyscheme = models.BooleanField(
"Supports AgencyScheme",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.AgencyScheme">'
'AgencyScheme '
'</a> or not.',
default=True,
)
supports_categoryscheme = models.BooleanField(
"Supports CategoryScheme",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.CategoryScheme">'
'CategoryScheme '
'</a> or not.',
default=True,
)
supports_codelist = models.BooleanField(
"Supports CodeList",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.CodeList">'
'CodeList '
'</a> or not.',
default=True,
)
supports_conceptscheme = models.BooleanField(
"Supports ConceptScheme",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.ConceptScheme">'
'ConceptScheme '
'</a> or not.',
default=True,
)
supports_data = models.BooleanField(
"Supports DataSet",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataSet">'
'DataSet '
'</a> or not.',
default=True,
)
supports_dataflow = models.BooleanField(
"Supports DataflowDefinition",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataflowDefinition">'
'DataflowDefinition '
'</a> or not.',
default=True,
)
supports_datastructure = models.BooleanField(
"Supports DataStructureDefinition",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.DataStructureDefinition">'
'CategoryScheme '
'</a> or not.',
default=True,
)
supports_provisionagreement = models.BooleanField(
"Supports ProvisionAgreement",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.model.ProvisionAgreement">'
'CategoryScheme '
'</a> or not.',
default=True,
)
supports_preview = models.BooleanField(
"Supports previews",
help_text='Whether the data source supports '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.Request.preview_data">'
'previews of data '
'</a> or not.',
default=False,
)
supports_structurespecific_data = models.BooleanField(
"Supports structure-specific data messages",
help_text='Whether the data source returns '
'<a href="https://pandasdmx.readthedocs.io/en/latest/api.html#pandasdmx.source.Source">'
'structure-specific data messages '
'</a> or not.',
default=False,
)
def supports_dict(self) -> dict:
return {
"agencyscheme": self.supports_agencyscheme,
"categoryscheme": self.supports_categoryscheme,
"codelist": self.supports_codelist,
"conceptscheme": self.supports_conceptscheme,
"data": self.supports_data,
"dataflow": self.supports_dataflow,
"datastructure": self.supports_datastructure,
"provisionagreement": self.supports_provisionagreement,
"preview": self.supports_preview,
"structure-specific data": self.supports_structurespecific_data,
}
def info_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"data_content_type": self.data_content_type,
"url": self.url,
"documentation": self.documentation,
"supports": self.supports_dict(),
"headers": self.headers,
"resources": self.resources,
}
builtin = models.BooleanField(
"Builtin",
help_text="Whether the source is built-in in PandaSDMX or not.",
)
@classmethod @classmethod
def create_from_sources_json(cls, file: t.TextIO): @abc.abstractmethod
j_sources: list = json.load(file) def get_fields(cls) -> set[str]:
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:
""" """
Convert the :class:`.DataSource` to a :class:`pandasdmx.source.Source`\\ . :return: The :class:`set` of field names (as :class:`str`) that will be serialized in the ``list`` and ``retrieve`` actions.
:return: The :class:`pandasdmx.source.Source`\\ .
.. todo:: :func:`.to_pandasdmx` does not currently support non :attr:`.builtin` sources.
"""
return pandasdmx.source.sources[self.id]
def to_pandasdmx_request(self) -> pandasdmx.Request:
"""
Convert the :class:`.DataSource` to a :class:`pandasdmx.Request` client.
:return: The :class:`pandasdmx.Request`\\ .
"""
return pandasdmx.Request(source=self.to_pandasdmx_source().id)
last_sync = models.DateTimeField(
"Last updated",
help_text="The datetime at which the data flows of this source were last syncronized.",
null=True,
)
def request_flows(self) -> tuple[pandas.Series, pandas.Series]:
"""
Retrieve all available dataflows and datastructures as two :class:`pandas.Series`\\ .
:return: A :class:`tuple` containing all dataflows and all datastructures.
.. note:: This seems to be an expensive operation, as it may take a few minutes to execute.
.. todo:: This function assumes both ``dataflow`` and ``structure`` will always be available.
Can something happen to make at least one of them :data:`None` ?
"""
source = self.to_pandasdmx_request()
message: pandasdmx.message.Message = source.dataflow()
data: dict[str, pandas.Series] = message.to_pandas()
flows = data["dataflow"]
structs = data["structure"]
return flows, structs
def sync_flows(self) -> None:
"""
Create :class:`.DataFlow` objects for every dataflow returned by :meth:`.request_flows`, and update the ones
that already exist.
.. warning:: This function does not delete any :class:`.DataFlow`, even if it doesn't exist anymore!
""" """
log.debug(f"Requesting dataflows of {self!r}...") raise NotImplementedError()
flows, structs = self.request_flows()
log.info(f"Syncing DataFlows of {self!r}...") @classmethod
for description, sdmx_id in zip(flows, flows.index): def get_view_serializer(cls) -> typing.Type[ModelSerializer]:
db_flow, _created = DataFlow.objects.update_or_create( """
**{ :return: The :class:`.serializers.ModelSerializer` containing this object's fields in _read-only_ mode.
"datasource": self, """
"sdmx_id": sdmx_id,
},
defaults={
"description": description,
}
)
db_flow.save()
log.debug(f"Synced {db_flow}!")
log.debug(f"Updating last_sync value of {self!r}") class ViewSerializer(ModelSerializer):
self.last_sync = timezone.now() class Meta:
self.save() model = cls
log.info(f"Finished syncing DataFlows of {self!r}") fields = list(cls.get_fields())
read_only_fields = fields
def __str__(self): return ViewSerializer
return self.id
@classmethod
@abc.abstractmethod
def get_editable_fields(cls) -> set[str]:
"""
:return: The :class:`set` of field names (as :class:`str`) that users with the **Edit** or **Admin** permission should be able to edit.
"""
raise NotImplementedError()
@classmethod
def get_non_editable_fields(cls) -> set[str]:
"""
:return: The :class:`set` of field names (as :class:`str`) that will be _read-only_ for an user with the **Edit** or **Admin** permission.
"""
return set.difference(
cls.get_fields(),
cls.get_editable_fields(),
)
@abc.abstractmethod
def can_edit(self, user: User) -> bool:
"""
:param user: The user to check the **Edit** permission of.
:return: :data:`True` if the user can edit the object's editable fields, :data:`False` otherwise.
"""
raise NotImplementedError()
@classmethod
def get_edit_serializer(cls) -> typing.Type[ModelSerializer]:
"""
:return: The :class:`.serializers.ModelSerializer` which allows the user to edit the fields specified in :meth:`.get_editable_fields`.
"""
class EditSerializer(ModelSerializer):
class Meta:
model = cls
fields = list(cls.get_fields())
read_only_fields = list(cls.get_non_editable_fields())
return EditSerializer
@classmethod
@abc.abstractmethod
def get_administrable_fields(cls) -> set[str]:
"""
:return: The :class:`set` of field names (as :class:`str`) that users with the **Admin** permission should be able to edit.
"""
raise NotImplementedError()
@classmethod
def get_non_administrable_fields(cls) -> set[str]:
"""
:return: The :class:`set` of field names (as :class:`str`) that will be _read-only_ for an user with the **Admin** permission.
"""
return set.difference(
cls.get_fields(),
cls.get_administrable_fields(),
)
@abc.abstractmethod
def can_admin(self, user: User) -> bool:
"""
:param user: The user to check the **Admin** permission of.
:return: :data:`True` if the user can edit the object's administrable fields, :data:`False` otherwise.
"""
raise NotImplementedError()
@classmethod
def get_admin_serializer(cls) -> typing.Type[ModelSerializer]:
"""
:return: A :class:`.serializers.ModelSerializer` which allows the user to edit the fields specified in :meth:`.get_editable_fields` and
:meth:`.get_administrable_fields`.
"""
class AdminSerializer(ModelSerializer):
class Meta:
model = cls
fields = list(cls.get_fields())
read_only_fields = list(cls.get_non_administrable_fields())
return AdminSerializer
@classmethod
@abc.abstractmethod
def get_creation_fields(cls) -> set[str]:
"""
:return: The :class:`set` of field names (as :class:`str`) that users should be able to specify when creating a new object of this class.
"""
raise NotImplementedError()
@classmethod
def get_creation_serializer(cls) -> typing.Type[ModelSerializer]:
"""
:return: A :class:`.serializers.ModelSerializer` which allows the user to define the fields specified in :meth:`.get_creation_fields`.
"""
class CreateSerializer(ModelSerializer):
class Meta:
model = cls
fields = list(cls.get_creation_fields())
return CreateSerializer
class DataFlow(models.Model): # noinspection PyAbstractClass
class SophonGroupModel(SophonModel):
""" """
A :class:`.DataFlow` is a object containing the metadata of a SDMX data set. The **abstract** base class for database objects belonging to a :class:`.ResearchGroup`.
See `this page <https://ec.europa.eu/eurostat/online-help/redisstat-admin/en/TECH_A_main/>`_ for more details. .. warning:: Since its metaclass is :class:`django.db.ModelBase`, the :class:`abc.ABCMeta` metaclass can't be applied, so method implementation cannot be
checked at runtime.
""" """
surrogate_id = models.BigAutoField( class Meta:
"Surrogate id", abstract = True
help_text="Internal id used by Django to identify this DataFlow.",
primary_key=True,
)
datasource = models.ForeignKey( @abc.abstractmethod
DataSource, def get_group(self) -> ResearchGroup:
help_text="The DataSource this object belongs to.", """
on_delete=models.RESTRICT, :return: The :class:`.ResearchGroup` this objects belongs to.
) """
raise NotImplementedError()
sdmx_id = models.CharField( @classmethod
"SDMX id", def get_access_to_edit(cls) -> SophonGroupAccess:
help_text="Internal string used in SDMX communication to identify the DataFlow.", """
max_length=64, :return: The minimum required :class:`.SophonGroupAccess` to **Edit** this object.
) """
return SophonGroupAccess.MEMBER
description = models.TextField( def can_edit(self, user: User) -> bool:
"Description", current = self.get_group().get_access(user)
help_text="Natural language description of the DataFlow.", required = self.get_access_to_edit()
blank=True, return current >= required
)
def __str__(self): @classmethod
return f"[{self.datasource}] {self.sdmx_id}" 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`. A :class:`.ResearchGroup` is a group of users which collectively own :class:`.ResearchProjects`.
""" """
slug = models.SlugField( slug = models.SlugField(
"Slug", "Slug",
help_text="Unique alphanumeric string which identifies the group.", help_text="Unique alphanumeric string which identifies the group in the Sophon instance.",
max_length=64, max_length=64,
primary_key=True, primary_key=True,
) )
name = models.CharField( name = models.CharField(
"Name", "Name",
help_text="The display name of the group.", help_text="The displayed name of the group.",
max_length=512, max_length=512,
) )
description = models.TextField( description = models.TextField(
"Description", "Description",
help_text="A brief description of what the group is about, to be displayed in the overview.", help_text="A brief description of what the group is about.",
blank=True,
)
members = models.ManyToManyField(
User,
help_text="The users who belong to this group.",
related_name="is_a_member_of",
blank=True, blank=True,
) )
owner = models.ForeignKey( owner = models.ForeignKey(
User, User,
help_text="The user who created the group, and therefore can add other users to it.", help_text="The user who created the group, who is automatically a member.",
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
members = models.ManyToManyField(
User,
help_text="The users who belong to this group, including the owner.",
related_name="is_a_member_of",
blank=True,
)
access = models.CharField( access = models.CharField(
"Access", "Access",
help_text="A setting specifying how can users join this group.", help_text="A setting specifying how users can join the group.",
choices=[ choices=[
("MANUAL", "⛔️ Collaborators must be added manually"), ("MANUAL", "⛔️ Collaborators must be added manually"),
# ("REQUEST", "✉️ Users can request an invite from the group owner"),
("OPEN", "❇️ Users can join the group freely"), ("OPEN", "❇️ Users can join the group freely"),
], ],
default="MANUAL", default="MANUAL",
max_length=16, max_length=16,
) )
def can_be_edited_by(self, user) -> bool: def get_group(self) -> ResearchGroup:
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: if user.is_superuser:
return True return SophonGroupAccess.SUPERUSER
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
elif user == self.owner: elif user == self.owner:
return True return SophonGroupAccess.OWNER
elif user in self.members.all():
return False return SophonGroupAccess.MEMBER
elif not user.is_anonymous:
def __str__(self): return SophonGroupAccess.REGISTERED
return f"[{self.name}]"
class ResearchProject(models.Model):
"""
A :class:`.ResearchProject` is a work which may use zero or more :class:`.DataSource`\\ s to prove or disprove an
hypothesis.
"""
slug = models.SlugField(
"Slug",
help_text="Unique alphanumeric string which identifies the project.",
max_length=64,
primary_key=True,
)
name = models.CharField(
"Name",
help_text="The display name of the project.",
max_length=512,
)
description = models.TextField(
"Description",
help_text="A brief description of the project, to be displayed in the overview.",
blank=True,
)
visibility = models.CharField(
"Visibility",
help_text="A setting specifying who can view the project contents.",
choices=[
("PUBLIC", "🌍 Public"),
("INTERNAL", "🏭 Internal"),
("PRIVATE", "🔒 Private"),
],
default="INTERNAL",
max_length=16,
)
group = models.ForeignKey(
ResearchGroup,
help_text="The group this project belongs to.",
on_delete=models.CASCADE,
)
tags = models.ManyToManyField(
ResearchTag,
help_text="The tags this project has been tagged with.",
related_name="tagged",
blank=True,
)
flows = models.ManyToManyField(
DataFlow,
help_text="The DataFlows used in this project.",
related_name="used_in",
blank=True,
)
def can_be_viewed_by(self, user) -> bool:
if user.is_superuser:
return True
elif self.visibility == "PUBLIC":
return True
elif self.visibility == "INTERNAL":
return not user.is_anonymous()
elif self.visibility == "PRIVATE":
return user in self.group.members
else: else:
raise ValueError(f"Unknown visibility value: {self.visibility}") return SophonGroupAccess.NONE
def can_be_edited_by(self, user) -> bool:
if user.is_superuser:
return True
elif user in self.group.members:
return True
return False
def can_be_administrated_by(self, user) -> bool:
if user.is_superuser:
return True
elif user in self.group.members:
return True
return False
def __str__(self): def __str__(self):
return f"{self.slug}" return f"{self.name}"

View file

@ -0,0 +1,32 @@
"""
This module contains some extensions to the :mod:`rest_framework.permissions` framework.
"""
from rest_framework.permissions import BasePermission, AllowAny
class Edit(BasePermission):
"""
Authorize only users who :meth:`~sophon.core.models.SophonGroupModel.can_edit` the requested object.
"""
def has_object_permission(self, request, view, obj):
return obj.can_edit(request.user)
class Admin(BasePermission):
"""
Authorize only users who :meth:`~sophon.core.models.SophonGroupModel.can_admin` the requested object.
"""
def has_object_permission(self, request, view, obj):
return obj.can_admin(request.user)
# Specify __all__ so that this can be imported as *
__all__ = (
"BasePermission",
"AllowAny",
"Edit",
"Admin",
)

View file

@ -1,234 +1,9 @@
from rest_framework import serializers from rest_framework.serializers import Serializer
from . import models
class DataSourceSerializer(serializers.ModelSerializer): class NoneSerializer(Serializer):
""" def update(self, instance, validated_data):
Serializer for :class:`.models.DataSource` . return None
"""
class Meta: def create(self, validated_data):
model = models.DataSource return None
fields = [
"id",
"name",
"description",
"url",
"documentation",
"data_content_type",
"headers",
"supports_agencyscheme",
"supports_categoryscheme",
"supports_codelist",
"supports_conceptscheme",
"supports_data",
"supports_dataflow",
"supports_datastructure",
"supports_provisionagreement",
"supports_preview",
"supports_structurespecific_data",
"builtin",
"last_sync",
]
read_only_fields = [
"builtin",
"last_sync",
]
class DataFlowSerializer(serializers.ModelSerializer):
"""
Serializer for :class:`.models.DataFlow` .
"""
class Meta:
model = models.DataFlow
fields = [
"surrogate_id",
"datasource",
"sdmx_id",
"description",
]
read_only_fields = [
"surrogate_id",
]
class ResearchGroupPublicSerializer(serializers.ModelSerializer):
"""
Serializer for users who are not administrators of a :class:`.models.ResearchGroup`.
"""
class Meta:
model = models.ResearchGroup
fields = (
"slug",
"name",
"description",
"owner",
"members",
"access",
)
read_only_fields = (
"slug",
"name",
"description",
"owner",
"members",
"access",
)
class ResearchGroupAdminSerializer(serializers.ModelSerializer):
"""
Serializer for users who are administrators of a :class:`.models.ResearchGroup`.
"""
class Meta:
model = models.ResearchGroup
fields = (
"slug",
"name",
"description",
"owner",
"members",
"access",
)
read_only_fields = (
"slug",
"owner",
)
class ResearchProjectPublicSerializer(serializers.ModelSerializer):
"""
Serializer for users who are not collaborators of a :class:`~.models.ResearchProject` and do not have permissions to view it.
"""
class Meta:
model = models.ResearchProject
fields = (
"slug",
"visibility",
"group",
)
read_only_fields = (
"slug",
"visibility",
"group",
)
class ResearchTagPublicSerializer(serializers.ModelSerializer):
"""
Serializer for users who are not owners of a :class:`.models.ResearchTag`.
"""
# TODO: Add a list of projects with the tag
class Meta:
model = models.ResearchTag
fields = (
"slug",
"name",
"description",
"color",
"owner",
)
read_only_fields = (
"slug",
"name",
"description",
"color",
"owner",
)
class ResearchTagAdminSerializer(serializers.ModelSerializer):
"""
Serializer for users who are owners of a :class:`.models.ResearchTag`.
"""
# TODO: Add a list of projects with the tag
class Meta:
model = models.ResearchTag
fields = (
"slug",
"name",
"description",
"color",
"owner",
)
read_only_fields = (
"slug",
"owner",
)
class ResearchProjectViewerSerializer(serializers.ModelSerializer):
"""
Serializer for users who are not collaborators of a :class:`~.models.ResearchProject`, but have permissions to view it.
"""
class Meta:
model = models.ResearchProject
fields = (
"slug",
"name",
"description",
"visibility",
"group",
"flows",
)
read_only_fields = (
"slug",
"name",
"description",
"visibility",
"group",
"flows",
)
class ResearchProjectCollaboratorSerializer(serializers.ModelSerializer):
"""
Serializer for users who are collaborators of a :class:`~.models.ResearchProject`, but not administrators.
"""
class Meta:
model = models.ResearchProject
fields = (
"slug",
"name",
"description",
"visibility",
"group",
"flows",
)
read_only_fields = (
"slug",
"visibility",
"group",
)
class ResearchProjectAdminSerializer(serializers.ModelSerializer):
"""
Serializer for users who are administrators of a :class:`~.models.ResearchProject`.
"""
class Meta:
model = models.ResearchProject
fields = (
"slug",
"name",
"description",
"visibility",
"group",
"flows",
)
read_only_fields = (
"slug",
)

View file

@ -1,14 +1,12 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter import rest_framework.routers
from . import views from . import views
router = DefaultRouter()
router.register("datasources", views.DataSourceViewSet) router = rest_framework.routers.DefaultRouter()
router.register("dataflows", views.DataFlowViewSet) router.register("groups", views.ResearchGroupViewSet, basename="research-group")
router.register("projects", views.ResearchProjectViewSet)
router.register("tags", views.ResearchTagViewSet)
router.register("groups", views.ResearchGroupViewSet)
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),

View file

@ -1,171 +1,247 @@
from logging import getLogger import abc
import typing as t
from rest_framework import viewsets, decorators, response, permissions, request as r import deprecation
from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from rest_framework.decorators import action
from rest_framework import status
from . import models, serializers from . import models
from .. import permissions as custom_permissions from . import permissions
from . import serializers
log = getLogger(__name__)
class ResearchProjectViewSet(viewsets.ModelViewSet): class HTTPException(Exception):
queryset = models.ResearchProject.objects.all() """
An exception that can be raised in :class:`.SophonViewSet` hooks to respond to a request with an HTTP error.
"""
def __init__(self, status: int):
self.status = status
def as_response(self) -> Response:
return Response(status=self.status)
class SophonViewSet(ModelViewSet, metaclass=abc.ABCMeta):
"""
An extension to :class:`~rest_framework.viewsets.ModelViewSet` including some essential (but missing) methods.
"""
# A QuerySet should be specified, probably
@abc.abstractmethod
def get_queryset(self):
raise NotImplementedError()
# Override the permission_classes property with this hack, as ModelViewSet doesn't have the get_permission_classes method yet
@property @property
def permission_classes(self): def permission_classes(self):
return { return self.get_permission_classes()
"list": [],
"create": [permissions.IsAuthenticated],
"retrieve": [custom_permissions.CanView],
"update": [custom_permissions.CanEdit],
"partial_update": [custom_permissions.CanEdit],
"destroy": [custom_permissions.CanAdministrate],
"metadata": [],
None: [],
}[self.action]
def get_serializer_class(self): # noinspection PyMethodMayBeStatic
if self.action == "list": def get_permission_classes(self) -> t.Collection[t.Type[permissions.BasePermission]]:
return serializers.ResearchProjectPublicSerializer
elif self.action == "create":
return serializers.ResearchProjectAdminSerializer
else:
project = self.get_object()
user = self.request.user
if project.can_be_administrated_by(user):
return serializers.ResearchProjectAdminSerializer
elif project.can_be_edited_by(user):
return serializers.ResearchProjectCollaboratorSerializer
elif project.can_be_viewed_by(user):
return serializers.ResearchProjectViewerSerializer
else:
return serializers.ResearchProjectPublicSerializer
class ResearchGroupViewSet(viewsets.ModelViewSet):
queryset = models.ResearchGroup.objects.all()
@property
def permission_classes(self):
return {
"list": [],
"create": [permissions.IsAuthenticated],
"retrieve": [permissions.IsAuthenticated],
"update": [custom_permissions.CanAdministrate],
"partial_update": [custom_permissions.CanAdministrate],
"destroy": [custom_permissions.CanAdministrate],
"metadata": [],
None: [],
}[self.action]
def get_serializer_class(self):
if self.action == "list":
return serializers.ResearchGroupPublicSerializer
elif self.action == "create":
return serializers.ResearchGroupAdminSerializer
else:
group = self.get_object()
user = self.request.user
if group.can_be_administrated_by(user):
return serializers.ResearchGroupAdminSerializer
else:
return serializers.ResearchGroupPublicSerializer
class ResearchTagViewSet(viewsets.ModelViewSet):
queryset = models.ResearchTag.objects.all()
@property
def permission_classes(self):
return {
"list": [],
"create": [permissions.IsAuthenticated],
"retrieve": [permissions.IsAuthenticated],
"update": [custom_permissions.CanAdministrate],
"partial_update": [custom_permissions.CanAdministrate],
"destroy": [custom_permissions.CanAdministrate],
"metadata": [],
None: [],
}[self.action]
def get_serializer_class(self):
if self.action == "list":
return serializers.ResearchTagPublicSerializer
elif self.action == "create":
return serializers.ResearchTagAdminSerializer
else:
group = self.get_object()
user = self.request.user
if group.can_be_administrated_by(user):
return serializers.ResearchTagAdminSerializer
else:
return serializers.ResearchTagPublicSerializer
class DataFlowViewSet(viewsets.ModelViewSet):
"""
Viewset for :class:`.models.DataFlow` instances.
"""
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):
""" """
Use Django and PostgreSQL's full text search capabilities to find DataFlows containing certain words in the The "method" version of the :attr:`~rest_framework.viewsets.ModelViewSet.permission_classes` property.
description.
:return: A collection of permission classes.
""" """
return permissions.AllowAny,
log.debug("Searching DataFlows...") # noinspection PyMethodMayBeStatic
if not (query := request.query_params.get("q")): def hook_create(self, serializer) -> dict[str, t.Any]:
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`\\ .
""" """
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: try:
db_datasource.sync_flows() hook = self.hook_create(serializer)
except NotImplementedError: except HTTPException as e:
return response.Response({ return e.as_response()
"success": False,
"error": "Syncing DataFlows is not supported on this DataSource."
}, 400)
except Exception as exc:
return response.Response({
"success": False,
"error": f"{exc}"
}, 500)
return response.Response({ serializer.save(**hook)
"success": True, headers = self.get_success_headers(serializer.data)
}) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
# noinspection PyMethodMayBeStatic
def hook_update(self, serializer) -> dict[str, t.Any]:
"""
Hook called on ``update`` and ``partial_update`` actions after the serializer is validated but before it is saved.
:param serializer: The validated serializer containing the data of the object about to be updated.
:raises HTTPException: If the request should be answered with an error.
:return: A :class:`dict` of fields to be added / overriden to the object saved by the serializer.
"""
return {}
@deprecation.deprecated(details="Use `.hook_update()` instead.")
def perform_update(self, serializer):
"""
.. warning:: This function does nothing and may not be called on :class:`SophonViewSet`\\ s.
"""
raise RuntimeError(f"`perform_update` may not be called on `SophonViewSet`s.")
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
try:
hook = self.hook_update(serializer)
except HTTPException as e:
return e.as_response()
serializer.save(**hook)
if getattr(instance, '_prefetched_objects_cache', None):
# If 'prefetch_related' has been applied to a queryset, we need to
# forcibly invalidate the prefetch cache on the instance.
instance._prefetched_objects_cache = {}
return Response(serializer.data)
# noinspection PyMethodMayBeStatic
def hook_destroy(self) -> None:
"""
Hook called on ``destroy`` before the object is deleted.
:raises HTTPException: If the request should be answered with an error.
"""
pass
@deprecation.deprecated(details="Use `.hook_destroy()` instead.")
def perform_destroy(self, serializer):
"""
.. warning:: This function does nothing and may not be called on :class:`SophonViewSet`\\ s.
"""
raise RuntimeError(f"`perform_destroy` may not be called on `SophonViewSet`s.")
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
try:
self.hook_destroy()
except HTTPException as e:
return e.as_response()
self.perform_destroy(instance)
return Response(status=status.HTTP_204_NO_CONTENT)
def get_serializer_class(self):
if self.action in ["list"]:
return self.get_queryset().model.get_view_serializer()
elif self.action in ["create", "metadata"]:
return self.get_queryset().model.get_creation_serializer()
elif self.action in ["retrieve", "update", "partial_update", "destroy"]:
return self.get_object().get_access_serializer(self.request.user)
else:
return self.get_custom_serializer_classes()
def get_custom_serializer_classes(self):
"""
.. todo:: Define this.
"""
return serializers.NoneSerializer
class ResearchGroupViewSet(SophonViewSet):
"""
The viewset for :class:`~.models.ResearchGroup`\\ s.
"""
def get_queryset(self):
# All research groups are public, so it's fine to do this
return models.ResearchGroup.objects.all()
def hook_create(self, serializer) -> dict[str, t.Any]:
# Add the owner field to the serializer
return {
"owner": self.request.user,
}
@action(detail=True, methods=["post"], name="Join group")
def join(self, request, pk) -> Response:
group = models.ResearchGroup.objects.get(pk=pk)
# Raise an error if the user is already in the group
if self.request.user in group.members.all():
return Response(status=status.HTTP_409_CONFLICT)
# Raise an error if the group doesn't allow member joins
if group.access != "OPEN":
return Response(status=status.HTTP_403_FORBIDDEN)
# Add the user to the group
group.members.add(self.request.user)
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], name="Leave group")
def leave(self, request, pk):
group = models.ResearchGroup.objects.get(pk=pk)
# Raise an error if the user is not in the group
if self.request.user not in group.members.all():
raise HTTPException(status.HTTP_409_CONFLICT)
# Raise an error if the user is the owner of the group
if self.request.user == group.owner:
raise HTTPException(status.HTTP_403_FORBIDDEN)
# Add the user to the group
group.members.remove(self.request.user)
return Response(status=status.HTTP_200_OK)
class SophonGroupViewSet(SophonViewSet, metaclass=abc.ABCMeta):
"""
A :class:`ModelViewSet` for objects belonging to a :class:`~.models.ResearchGroup`.
"""
@abc.abstractmethod
def get_group_from_serializer(self, serializer) -> models.ResearchGroup:
"""
:param serializer: The validated serializer containing the data of the object about to be created.
:return: The group the data in the serializer refers to.
"""
raise NotImplementedError()
def hook_create(self, serializer) -> dict[str, t.Any]:
# Allow creation of objects only on groups the user has Edit access on
group = self.get_group_from_serializer(serializer)
if not group.can_edit(self.request.user):
raise HTTPException(status.HTTP_403_FORBIDDEN)
return {}
def hook_update(self, serializer) -> dict[str, t.Any]:
# Allow group transfers only to groups the user has Edit access on
group: models.ResearchGroup = self.get_group_from_serializer(serializer)
if not group.can_edit(self.request.user):
raise HTTPException(status.HTTP_403_FORBIDDEN)
return {}
def get_permission_classes(self) -> t.Collection[t.Type[permissions.BasePermission]]:
if self.action in ["destroy", "update", "partial_update"]:
return permissions.Edit,
else:
return permissions.AllowAny,

View file

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

View file

View file

@ -0,0 +1,18 @@
from django.contrib import admin
from sophon.core.admin import SophonAdmin
from . import models
@admin.register(models.ResearchProject)
class ResearchProjectAdmin(SophonAdmin):
list_display = (
"slug",
"name",
"group",
"visibility",
)
ordering = (
"slug",
)

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
name = 'sophon.projects'
verbose_name = "Sophon Projects"

View file

@ -0,0 +1,29 @@
# Generated by Django 3.2 on 2021-08-10 21:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0002_alter_researchgroup_options'),
]
operations = [
migrations.CreateModel(
name='ResearchProject',
fields=[
('slug', models.SlugField(help_text='Unique alphanumeric string which identifies the project.', max_length=64, primary_key=True, serialize=False, verbose_name='Slug')),
('name', models.CharField(help_text='The display name of the project.', max_length=512, verbose_name='Name')),
('description', models.TextField(blank=True, help_text='A brief description of the project, to be displayed in the overview.', verbose_name='Description')),
('visibility', models.CharField(choices=[('PUBLIC', '🌍 Public'), ('INTERNAL', '🏭 Internal'), ('PRIVATE', '🔒 Private')], default='INTERNAL', help_text='A setting specifying who can view the project contents.', max_length=16, verbose_name='Visibility')),
('group', models.ForeignKey(help_text='The group this project belongs to.', on_delete=django.db.models.deletion.CASCADE, to='core.researchgroup')),
],
options={
'abstract': False,
},
),
]

View file

@ -0,0 +1,83 @@
from django.db import models
from sophon.core.models import SophonGroupModel, ResearchGroup
class ResearchProject(SophonGroupModel):
"""
A :class:`.ResearchProject` is a work which may use zero or more :class:`.DataSource`\\ s to prove or disprove an
hypothesis.
"""
slug = models.SlugField(
"Slug",
help_text="Unique alphanumeric string which identifies the project.",
max_length=64,
primary_key=True,
)
group = models.ForeignKey(
ResearchGroup,
help_text="The group this project belongs to.",
on_delete=models.CASCADE,
)
name = models.CharField(
"Name",
help_text="The display name of the project.",
max_length=512,
)
description = models.TextField(
"Description",
help_text="A brief description of the project, to be displayed in the overview.",
blank=True,
)
visibility = models.CharField(
"Visibility",
help_text="A setting specifying who can view the project contents.",
choices=[
("PUBLIC", "🌍 Public"),
("INTERNAL", "🏭 Internal"),
("PRIVATE", "🔒 Private"),
],
default="INTERNAL",
max_length=16,
)
def get_group(self) -> ResearchGroup:
return self.group
@classmethod
def get_fields(cls) -> set[str]:
return {
"slug",
"group",
"name",
"description",
"visibility",
}
@classmethod
def get_editable_fields(cls) -> set[str]:
return {
"name",
"description",
}
@classmethod
def get_administrable_fields(cls) -> set[str]:
return {
"group",
"visibility",
}
@classmethod
def get_creation_fields(cls) -> set[str]:
return {
"slug",
"group",
"name",
"description",
"visibility",
}

View file

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

View file

@ -0,0 +1,13 @@
from django.urls import path, include
import rest_framework.routers
from . import views
router = rest_framework.routers.DefaultRouter()
router.register("projects", views.ResearchProjectViewSet, basename="research-project")
urlpatterns = [
path("", include(router.urls)),
]

View file

@ -0,0 +1,23 @@
from django.db.models import Q
from sophon.core.models import ResearchGroup
from sophon.core.views import SophonGroupViewSet
from . import models
class ResearchProjectViewSet(SophonGroupViewSet):
def get_queryset(self):
if self.request.user.is_anonymous:
return models.ResearchProject.objects.filter(
Q(visibility="PUBLIC")
)
else:
return models.ResearchProject.objects.filter(
Q(visibility="PUBLIC") |
Q(visibility="INTERNAL") |
Q(visibility="PRIVATE", group__members__in=[self.request.user])
)
def get_group_from_serializer(self, serializer) -> ResearchGroup:
return serializer.validated_data["group"]

View file

@ -44,7 +44,8 @@ INSTALLED_APPS = [
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'colorfield', 'colorfield',
'sophon.core', # FIXME: Is .apps.CoreConfig not needed? 'sophon.core',
'sophon.projects',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -149,9 +150,9 @@ REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions, # Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users. # or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' 'rest_framework.permissions.AllowAny'
], ],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'DEFAULT_AUTHENTICATION_CLASSES': [ 'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',

View file

@ -22,4 +22,5 @@ urlpatterns = [
path('api/auth/token/', CustomObtainAuthToken.as_view()), path('api/auth/token/', CustomObtainAuthToken.as_view()),
path('api/auth/session/', include("rest_framework.urls")), path('api/auth/session/', include("rest_framework.urls")),
path('api/core/', include("sophon.core.urls")), path('api/core/', include("sophon.core.urls")),
path('api/projects/', include("sophon.projects.urls")),
] ]

View file

@ -0,0 +1,2 @@
POST http://127.0.0.1:30033/api/core/projects/

View file

@ -5,6 +5,7 @@ OPTIONS http://127.0.0.1:30033/api/auth/token/
POST http://127.0.0.1:30033/api/auth/token/ POST http://127.0.0.1:30033/api/auth/token/
Content-Type: application/json Content-Type: application/json
# Add here your username and password
{ {
"username": "", "username": "",
"password": "" "password": ""