Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Testing Infrastructure #87

Merged
merged 23 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5ef7507
fix: Add missing env vars
jjnesbitt Oct 16, 2024
a387d41
refactor: reorg dependencies
jjnesbitt Oct 16, 2024
c536c99
test: Add basic testing infrastructure
jjnesbitt Oct 17, 2024
2bdd2d2
refactor: Improve project perm functions
jjnesbitt Oct 21, 2024
2e9bb17
docs: Add comment
jjnesbitt Oct 22, 2024
5d425df
test: Add more project tests
jjnesbitt Oct 22, 2024
cb43a1f
test: Remove old api tests
jjnesbitt Oct 22, 2024
5dc04b6
test: Don't run slow tests by default
jjnesbitt Oct 22, 2024
18fa882
fix: Fix incorrect dataset permission logic
jjnesbitt Oct 22, 2024
8dcd62b
fix: Fix bug in project permission logic
jjnesbitt Oct 22, 2024
6f9a18e
test: Add dataset GCC tests
jjnesbitt Oct 24, 2024
737a3e6
test: Add celery task testing config
jjnesbitt Oct 24, 2024
e748ad6
refactor: Remove unused dataset endpoint
jjnesbitt Oct 24, 2024
b1744f4
test: Add dataset map layer factories and tests
jjnesbitt Oct 24, 2024
c200d4d
test: Add dataset network tests
jjnesbitt Oct 24, 2024
023da47
refactor: Remove unused endpoints
jjnesbitt Oct 24, 2024
1ff9408
test: Remove use of pytest-factoryboy
jjnesbitt Oct 24, 2024
01e7ae3
test: Simplify use of network edge fixtures
jjnesbitt Oct 24, 2024
2aeb5db
refactor: Remove unused map layer endpoints
jjnesbitt Oct 28, 2024
2545952
refactor: Remove unused simulation endpoints
jjnesbitt Oct 28, 2024
85e0415
fix: Fix linting errors
jjnesbitt Nov 7, 2024
879a256
ci: Add nightly CI for slow tests
jjnesbitt Nov 7, 2024
a22252a
refactor: Remove all dataset modification endpoints
jjnesbitt Nov 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/nightly_ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: nightly-ci
on:
schedule:
# Run at 1:14 am every night, to avoid high load at common schedule times.
- cron: "14 1 * * *"

jobs:
test:
runs-on: ubuntu-22.04
services:
postgres:
image: postgis/postgis:14-3.3
env:
POSTGRES_DB: django
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
rabbitmq:
image: rabbitmq:management
ports:
- 5672:5672
minio:
image: bitnami/minio:latest
env:
MINIO_ROOT_USER: minioAccessKey
MINIO_ROOT_PASSWORD: minioSecretKey
ports:
- 9000:9000
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install tox
run: |
pip install --upgrade pip
pip install tox
- name: Install GDAL
run: |
sudo apt-add-repository ppa:ubuntugis/ppa
sudo apt-get update
sudo apt-get install gdal-bin libgdal-dev
pip install GDAL==`gdal-config --version`
- name: Run tests
run: |
tox -e test -- -k "slow"
env:
DJANGO_DATABASE_URL: postgres://postgres:postgres@localhost:5432/django
DJANGO_MINIO_STORAGE_ENDPOINT: localhost:9000
DJANGO_MINIO_STORAGE_ACCESS_KEY: minioAccessKey
DJANGO_MINIO_STORAGE_SECRET_KEY: minioSecretKey
DJANGO_HOMEPAGE_REDIRECT_URL: http://localhost:8080/
1 change: 1 addition & 0 deletions dev/.env.docker-compose
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ DJANGO_MINIO_STORAGE_ACCESS_KEY=minioAccessKey
DJANGO_MINIO_STORAGE_SECRET_KEY=minioSecretKey
DJANGO_STORAGE_BUCKET_NAME=django-storage
DJANGO_MINIO_STORAGE_MEDIA_URL=http://localhost:9000/django-storage
DJANGO_HOMEPAGE_REDIRECT_URL=http://localhost:8080/
1 change: 1 addition & 0 deletions dev/.env.docker-compose-native
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ DJANGO_MINIO_STORAGE_ENDPOINT=localhost:9000
DJANGO_MINIO_STORAGE_ACCESS_KEY=minioAccessKey
DJANGO_MINIO_STORAGE_SECRET_KEY=minioSecretKey
DJANGO_STORAGE_BUCKET_NAME=django-storage
DJANGO_HOMEPAGE_REDIRECT_URL=http://localhost:8080/
6 changes: 6 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,11 @@
'ipython==8.26.0',
'tox==4.16.0',
],
'test': [
'factory-boy==3.3.1',
'pytest==8.3.3',
'pytest-django==4.9.0',
'pytest-mock==3.14.0',
],
},
)
11 changes: 4 additions & 7 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,10 @@ passenv =
DJANGO_HOMEPAGE_REDIRECT_URL
extras =
dev
deps =
factory-boy
pytest
pytest-django
pytest-factoryboy
pytest-mock
test
commands =
pip install large-image-converter --find-links https://girder.github.io/large_image_wheels
pytest {posargs}
pytest {posargs:-k "not slow"}

[testenv:check-migrations]
setenv =
Expand Down Expand Up @@ -94,6 +89,8 @@ exclude =
DJANGO_SETTINGS_MODULE = uvdat.settings
DJANGO_CONFIGURATION = TestingConfiguration
addopts = --strict-markers --showlocals --verbose
markers =
slow: mark test as slow
filterwarnings =
# https://github.com/jazzband/django-configurations/issues/190
ignore:the imp module is deprecated in favour of importlib:DeprecationWarning:configurations
Expand Down
72 changes: 60 additions & 12 deletions uvdat/core/models/project.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import typing

from django.contrib.auth.models import User
from django.contrib.gis.db import models as geo_models
from django.db import models, transaction
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from guardian.shortcuts import assign_perm, get_users_with_perms

from .dataset import Dataset

Expand All @@ -13,27 +15,73 @@ class Project(models.Model):
default_map_zoom = models.FloatField(default=10)
datasets = models.ManyToManyField(Dataset, blank=True)

def owner(self) -> User:
users = typing.cast(
list[User], list(get_users_with_perms(self, only_with_perms_in=['owner']))
)
if len(users) != 1:
raise Exception('Project must have exactly one owner')

return users[0]

def collaborators(self) -> list[User]:
return typing.cast(
list[User], list(get_users_with_perms(self, only_with_perms_in=['collaborator']))
)

def followers(self):
return typing.cast(
list[User], list(get_users_with_perms(self, only_with_perms_in=['follower']))
)

@transaction.atomic()
def set_permissions(
self,
owner: User,
collaborator: list[User] | None = None,
follower: list[User] | None = None,
):
# Delete all existing first
def delete_users_perms(self, users: list[User]):
"""Delete all permissions a user may have on this project."""
user_ids = [user.id for user in users]
UserObjectPermission.objects.filter(
content_type__app_label=self._meta.app_label,
content_type__model=self._meta.model_name,
object_pk=self.pk,
user_id__in=user_ids,
).delete()

@transaction.atomic()
def set_owner(self, user: User):
# Remove existing owner
UserObjectPermission.objects.filter(
content_type__app_label=self._meta.app_label,
content_type__model=self._meta.model_name,
object_pk=self.pk,
permission__codename='owner',
).delete()

# Assign new perms
assign_perm('owner', owner, self)
for user in collaborator or []:
# Delete any existing user perms and set owner
self.delete_users_perms([user])
assign_perm('owner', user, self)

@transaction.atomic()
def add_collaborators(self, users: list[User]):
self.delete_users_perms(users)
for user in users:
assign_perm('collaborator', user, self)
for user in follower or []:

@transaction.atomic()
def add_followers(self, users: list[User]):
self.delete_users_perms(users)
for user in users:
assign_perm('follower', user, self)

@transaction.atomic()
def set_permissions(
self,
owner: User,
collaborator: list[User] | None = None,
follower: list[User] | None = None,
):
self.set_owner(owner)
self.add_collaborators(collaborator or [])
self.add_followers(follower or [])

class Meta:
permissions = [
('owner', 'Can read, write, and delete'),
Expand Down
6 changes: 0 additions & 6 deletions uvdat/core/rest/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from .chart import ChartViewSet
from .dataset import DatasetViewSet
from .file_item import FileItemViewSet
from .map_layers import RasterMapLayerViewSet, VectorMapLayerViewSet
from .network import NetworkEdgeViewSet, NetworkNodeViewSet, NetworkViewSet
from .project import ProjectViewSet
from .regions import SourceRegionViewSet
from .simulations import SimulationViewSet
Expand All @@ -11,12 +9,8 @@
__all__ = [
ProjectViewSet,
ChartViewSet,
FileItemViewSet,
RasterMapLayerViewSet,
VectorMapLayerViewSet,
NetworkViewSet,
NetworkNodeViewSet,
NetworkEdgeViewSet,
DatasetViewSet,
SourceRegionViewSet,
SimulationViewSet,
Expand Down
8 changes: 6 additions & 2 deletions uvdat/core/rest/access_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
from uvdat.core.models.project import Project


# TODO: Dataset permissions should be separated from Project permissions
def filter_queryset_by_projects(queryset: QuerySet[Model], projects: QuerySet[models.Project]):
model = queryset.model

# Dataset permissions not yet implemented, and as such, all datasets are visible to all users
if model == models.Dataset:
return queryset

if model == models.Project:
return queryset.filter(id__in=projects.values_list('id', flat=True))
if model in [models.Dataset, models.Chart, models.SimulationResult]:
if model in [models.Chart, models.SimulationResult]:
return queryset.filter(project__in=projects)
if model in [
models.FileItem,
Expand Down
36 changes: 20 additions & 16 deletions uvdat/core/rest/dataset.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import json

from django.http import HttpResponse
from drf_yasg.utils import swagger_auto_schema
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ReadOnlyModelViewSet

from uvdat.core.models import Dataset, Network, NetworkEdge, NetworkNode
from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission
Expand All @@ -17,7 +16,12 @@
from uvdat.core.tasks.chart import add_gcc_chart_datum


class DatasetViewSet(ModelViewSet):
class GCCQueryParamSerializer(serializers.Serializer):
project = serializers.IntegerField()
exclude_nodes = serializers.RegexField(r'^\d+(,\s?\d+)*$')


class DatasetViewSet(ReadOnlyModelViewSet):
queryset = Dataset.objects.all()
serializer_class = DatasetSerializer
permission_classes = [GuardianPermission]
Expand Down Expand Up @@ -49,12 +53,6 @@ def map_layers(self, request, **kwargs):
# Return response with rendered data
return Response(serializer.data, status=200)

@action(detail=True, methods=['get'])
def convert(self, request, **kwargs):
dataset = self.get_object()
dataset.spawn_conversion_task()
return HttpResponse(status=200)

@action(detail=True, methods=['get'])
def network(self, request, **kwargs):
dataset = self.get_object()
Expand All @@ -72,15 +70,21 @@ def network(self, request, **kwargs):
],
}
)
return HttpResponse(json.dumps(networks), status=200)
return Response(networks, status=200)

@swagger_auto_schema(query_serializer=GCCQueryParamSerializer)
@action(detail=True, methods=['get'])
def gcc(self, request, **kwargs):
dataset = self.get_object()
project_id = request.query_params.get('project')
exclude_nodes = request.query_params.get('exclude_nodes', [])
exclude_nodes = exclude_nodes.split(',')
exclude_nodes = [int(n) for n in exclude_nodes if len(n)]

# Validate and de-serialize query params
serializer = GCCQueryParamSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
project_id = serializer.validated_data['project']
exclude_nodes = [int(n) for n in serializer.validated_data['exclude_nodes'].split(',')]

if not dataset.networks.exists():
return Response(data='No networks exist in selected dataset', status=400)

# Find the GCC for each network in the dataset
network_gccs: list[list[int]] = []
Expand Down
13 changes: 0 additions & 13 deletions uvdat/core/rest/file_item.py

This file was deleted.

7 changes: 4 additions & 3 deletions uvdat/core/rest/map_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from django.db import connection
from django.http import HttpResponse
from django_large_image.rest import LargeImageFileDetailMixin
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import GenericViewSet

from uvdat.core.models import RasterMapLayer, VectorMapLayer
from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission
Expand Down Expand Up @@ -70,7 +71,7 @@
"""


class RasterMapLayerViewSet(ModelViewSet, LargeImageFileDetailMixin):
class RasterMapLayerViewSet(GenericViewSet, mixins.RetrieveModelMixin, LargeImageFileDetailMixin):
queryset = RasterMapLayer.objects.select_related('dataset').all()
serializer_class = RasterMapLayerSerializer
permission_classes = [GuardianPermission]
Expand All @@ -90,7 +91,7 @@ def get_raster_data(self, request, resolution: str = '1', **kwargs):
return HttpResponse(json.dumps(data), status=200)


class VectorMapLayerViewSet(ModelViewSet):
class VectorMapLayerViewSet(GenericViewSet, mixins.RetrieveModelMixin):
queryset = VectorMapLayer.objects.select_related('dataset').all()
serializer_class = VectorMapLayerSerializer
permission_classes = [GuardianPermission]
Expand Down
33 changes: 0 additions & 33 deletions uvdat/core/rest/network.py

This file was deleted.

Loading
Loading