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="PyInterpreterInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PyMandatoryEncodingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="PyMethodParametersInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PyMissingTypeHintsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="PyNestedDecoratorsInspection" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyNoneFunctionAssignmentInspection" enabled="true" level="WARNING" enabled_by_default="true" />

View file

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

44
backend/poetry.lock generated
View file

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

View file

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

View file

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

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

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

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 . 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

View file

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

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 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):
# noinspection PyMethodMayBeStatic
def get_permission_classes(self) -> t.Collection[t.Type[permissions.BasePermission]]:
"""
Viewset for :class:`.models.DataFlow` instances.
The "method" version of the :attr:`~rest_framework.viewsets.ModelViewSet.permission_classes` property.
:return: A collection of permission classes.
"""
return permissions.AllowAny,
queryset = models.DataFlow.objects.all()
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 hook_create(self, serializer) -> dict[str, t.Any]:
"""
Use Django and PostgreSQL's full text search capabilities to find DataFlows containing certain words in the
description.
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 {}
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):
@deprecation.deprecated(details="Use `.hook_create()` instead.")
def perform_create(self, serializer):
"""
Viewset for :class:`.models.DataSource` instances.
.. warning:: This function does nothing and may not be called on :class:`SophonViewSet`\\ s.
"""
raise RuntimeError(f"`perform_create` may not be called on `SophonViewSet`s.")
queryset = models.DataSource.objects.all()
serializer_class = serializers.DataSourceSerializer
permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly]
def create(self, request, *args, **kwargs):
serializer: Serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
@decorators.action(methods=["post"], detail=True)
def sync(self, request: r.Request, *args, **kwargs):
"""
Syncronize the :class:`.models.DataFlow`\\ s with the ones stored in the server of the
:class:`.models.DataSource`\\ .
"""
log.debug(f"Getting DataSource from the database...")
db_datasource: models.DataSource = self.get_object()
try:
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,

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.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',

View file

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

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/
Content-Type: application/json
# Add here your username and password
{
"username": "",
"password": ""