Skip to content

Commit bab7da5

Browse files
authored
Merge pull request #100 from opensafely-core/container-clean-ups
container clean ups
2 parents fa37198 + e6f43a1 commit bab7da5

9 files changed

Lines changed: 93 additions & 84 deletions

File tree

airlock/api.py

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from datetime import datetime
44
from enum import Enum
55
from pathlib import Path
6-
from typing import Optional
6+
7+
# we use PurePosixPath as a convenient url path representation
8+
from pathlib import PurePosixPath as UrlPath
9+
from typing import Optional, Protocol
710

811
from django.conf import settings
912
from django.shortcuts import reverse
@@ -12,6 +15,9 @@
1215
from airlock.users import User
1316

1417

18+
ROOT_PATH = UrlPath() # empty path
19+
20+
1521
class Status(Enum):
1622
"""Status for release Requests"""
1723

@@ -25,27 +31,30 @@ class Status(Enum):
2531
RELEASED = "RELEASED"
2632

2733

28-
class AirlockContainer:
29-
"""Abstract class for a directory or workspace or request files.
34+
class AirlockContainer(Protocol):
35+
"""Structural typing class for a instance of a Workspace or ReleaseRequest
3036
31-
Provides a uniform interface for accessing information about this directory.
37+
Provides a uniform interface for accessing information about the paths and files
38+
contained within this instance.
3239
"""
3340

34-
def root(self):
35-
raise NotImplementedError()
41+
# The currently selected path in this container. Used to calculate how
42+
# a particular path relates to it, i.e. if path *is* the currently selected
43+
# path, or is one of its parents.
44+
selected_path: UrlPath = ROOT_PATH
3645

37-
def get_id(self):
38-
raise NotImplementedError()
46+
def root(self) -> Path:
47+
"""Absolute concrete Path to root dir for files in this container."""
3948

40-
def get_absolute_url(self):
41-
raise NotImplementedError()
49+
def get_id(self) -> str:
50+
"""Get the human name for this container."""
4251

43-
def get_url_for_path(self):
44-
raise NotImplementedError()
52+
def get_url(self, path: UrlPath = ROOT_PATH) -> str:
53+
"""Get the url for the container object with path"""
4554

4655

4756
@dataclass
48-
class Workspace(AirlockContainer):
57+
class Workspace:
4958
"""Simple wrapper around a workspace directory on disk.
5059
5160
Deliberately a dumb python object - the only operations are about accessing
@@ -54,6 +63,9 @@ class Workspace(AirlockContainer):
5463

5564
name: str
5665

66+
# can be set to mark the currently selected path in this workspace
67+
selected_path: UrlPath = ROOT_PATH
68+
5769
def __post_init__(self):
5870
if not self.root().exists():
5971
raise ProviderAPI.WorkspaceNotFound(self.name)
@@ -64,10 +76,7 @@ def root(self):
6476
def get_id(self):
6577
return self.name
6678

67-
def get_absolute_url(self):
68-
return reverse("workspace_home", kwargs={"workspace_name": self.name})
69-
70-
def get_url_for_path(self, relpath):
79+
def get_url(self, relpath=ROOT_PATH):
7180
return reverse(
7281
"workspace_view",
7382
kwargs={"workspace_name": self.name, "path": relpath},
@@ -99,7 +108,7 @@ class RequestFile:
99108
Represents a single file within a release request
100109
"""
101110

102-
relpath: Path
111+
relpath: UrlPath
103112

104113

105114
@dataclass(frozen=True)
@@ -113,7 +122,7 @@ class FileGroup:
113122

114123

115124
@dataclass
116-
class ReleaseRequest(AirlockContainer):
125+
class ReleaseRequest:
117126
"""Represents a release request made by a user.
118127
119128
Deliberately a dumb python object. Does not operate on the state of request,
@@ -129,6 +138,9 @@ class ReleaseRequest(AirlockContainer):
129138
status: Status = Status.PENDING
130139
filegroups: dict[FileGroup] = field(default_factory=dict)
131140

141+
# can be set to mark the currently selected path in this release request
142+
selected_path: UrlPath = ROOT_PATH
143+
132144
def __post_init__(self):
133145
self.root().mkdir(parents=True, exist_ok=True)
134146

@@ -138,15 +150,7 @@ def root(self):
138150
def get_id(self):
139151
return self.id
140152

141-
def get_absolute_url(self):
142-
return reverse(
143-
"request_home",
144-
kwargs={
145-
"request_id": self.id,
146-
},
147-
)
148-
149-
def get_url_for_path(self, relpath):
153+
def get_url(self, relpath=ROOT_PATH):
150154
return reverse(
151155
"request_view",
152156
kwargs={
@@ -324,7 +328,7 @@ def set_status(
324328
def add_file_to_request(
325329
self,
326330
release_request: ReleaseRequest,
327-
relpath: Path,
331+
relpath: UrlPath,
328332
user: User,
329333
group_name: Optional[str] = "default",
330334
):
@@ -370,7 +374,7 @@ def release_files(self, request: ReleaseRequest, user: User):
370374
)
371375

372376
for f in filelist.files:
373-
relpath = Path(f.name)
377+
relpath = UrlPath(f.name)
374378
old_api.upload_file(
375379
jobserver_release_id, relpath, request.root() / relpath, user.username
376380
)

airlock/file_browser_api.py

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import pathlib
21
from dataclasses import dataclass
32

4-
from airlock.api import AirlockContainer
3+
from airlock.api import ROOT_PATH, AirlockContainer, UrlPath
54

65

76
@dataclass(frozen=True)
87
class PathItem:
98
"""
10-
This provides a thin abstraction over `pathlib.Path` objects with two goals:
9+
This provides a thin abstraction over url paths and related filesystem objects with two goals:
1110
1211
1. Paths should be enforced as being relative to a certain "container" directory
1312
and it should not be possible to traverse outside of this directory or to
@@ -19,16 +18,11 @@ class PathItem:
1918
"""
2019

2120
container: AirlockContainer
22-
relpath: pathlib.Path
23-
24-
# The currently selected view path in this container. Used to calculate if
25-
# how relpath relates to it, i.e. if relpath *is* the currently selected
26-
# path, or is one of its parents.
27-
selected: pathlib.Path = None
21+
relpath: UrlPath = ROOT_PATH
2822

2923
def __post_init__(self):
3024
# ensure relpath is a Path
31-
object.__setattr__(self, "relpath", pathlib.Path(self.relpath))
25+
object.__setattr__(self, "relpath", UrlPath(self.relpath))
3226
# ensure path is within container
3327
self._absolute_path().resolve().relative_to(self.container.root())
3428

@@ -42,22 +36,25 @@ def is_directory(self):
4236
return self._absolute_path().is_dir()
4337

4438
def name(self):
39+
if self.relpath == ROOT_PATH:
40+
return self.container.get_id()
41+
4542
return self.relpath.name
4643

4744
def url(self):
4845
suffix = "/" if self.is_directory() else ""
49-
return self.container.get_url_for_path(f"{self.relpath}{suffix}")
46+
return self.container.get_url(f"{self.relpath}{suffix}")
5047

5148
def parent(self):
5249
if self.relpath.parents:
53-
return PathItem(self.container, self.relpath.parent, selected=self.selected)
50+
return PathItem(self.container, self.relpath.parent)
5451

5552
def children(self):
5653
if not self.is_directory():
5754
return []
5855
root = self.container.root()
5956
children = [
60-
PathItem(self.container, child.relative_to(root), selected=self.selected)
57+
PathItem(self.container, child.relative_to(root))
6158
for child in self._absolute_path().iterdir()
6259
]
6360
# directories first, then alphabetical, aks what windows does
@@ -85,8 +82,7 @@ def breadcrumbs(self):
8582

8683
parent = item.parent()
8784
while parent:
88-
if parent.relpath != pathlib.Path("."):
89-
crumbs.append(parent)
85+
crumbs.append(parent)
9086
parent = parent.parent()
9187

9288
crumbs.reverse()
@@ -113,10 +109,10 @@ def html_classes(self):
113109
return " ".join(classes)
114110

115111
def is_selected(self):
116-
return self.relpath == self.selected
112+
return self.relpath == self.container.selected_path
117113

118114
def is_on_selected_path(self):
119-
return self.relpath in self.selected.parents
115+
return self.relpath in self.container.selected_path.parents
120116

121117
def is_open(self):
122118
return self.is_selected() or self.is_on_selected_path()

airlock/templates/file_browser/index.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
</form>
7272
{% endif %}
7373
{% endif %}
74-
{% #button type="link" href=workspace.get_absolute_url variant="success" %}Workspace Home{% /button %}
74+
{% #button type="link" href=workspace.get_url variant="success" %}Workspace Home{% /button %}
7575
{% endif %}
7676
</div>
7777
{% endfragment %}
@@ -84,7 +84,6 @@
8484
{% /card %}
8585

8686
{% #breadcrumbs %}
87-
{% breadcrumb title=path_item.container.get_id url=path_item.container.get_absolute_url %}
8887
{% for crumb in path_item.breadcrumbs %}
8988
{% breadcrumb title=crumb.name url=crumb.url active=forloop.last %}
9089
{% endfor %}

airlock/templates/requests.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{% #card title="Requests by "|add:request.user.username %}
66
{% #list_group %}
77
{% for request in authored_requests %}
8-
{% #list_group_item href=request.get_absolute_url %}{{ request.workspace }}: {{ request.status.name }}{% /list_group_item %}
8+
{% #list_group_item href=request.get_url %}{{ request.workspace }}: {{ request.status.name }}{% /list_group_item %}
99
{% endfor %}
1010
{% /list_group %}
1111
{% /card %}
@@ -14,7 +14,7 @@
1414
{% #card title="Outstanding requests awaiting review" %}
1515
{% #list_group %}
1616
{% for request in outstanding_requests %}
17-
{% #list_group_item href=request.get_absolute_url %}{{ request.workspace }} by {{ request.author }}{% /list_group_item %}
17+
{% #list_group_item href=request.get_url %}{{ request.workspace }} by {{ request.author }}{% /list_group_item %}
1818
{% endfor %}
1919
{% /list_group %}
2020
{% /card %}

airlock/templates/workspaces.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{% #card title="Workspaces for "|add:request.user.username %}
55
{% #list_group %}
66
{% for workspace in workspaces %}
7-
{% #list_group_item href=workspace.get_absolute_url %}{{ workspace.name }}{% /list_group_item %}
7+
{% #list_group_item href=workspace.get_url %}{{ workspace.name }}{% /list_group_item %}
88
{% endfor %}
99
{% /list_group %}
1010
{% /card %}

airlock/urls.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
path(
3535
"workspaces/view/<str:workspace_name>/",
3636
airlock.views.workspace_view,
37-
name="workspace_home",
37+
kwargs={"path": ""},
38+
name="workspace_view",
3839
),
3940
path(
4041
"workspaces/view/<str:workspace_name>/<path:path>",
@@ -55,7 +56,8 @@
5556
path(
5657
"requests/view/<str:request_id>/",
5758
airlock.views.request_view,
58-
name="request_home",
59+
name="request_view",
60+
kwargs={"path": ""},
5961
),
6062
path(
6163
"requests/view/<str:request_id>/<path:path>",

airlock/views.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from pathlib import Path
2-
31
import requests
42
from django import forms
53
from django.conf import settings
@@ -12,7 +10,7 @@
1210
from django.views.decorators.http import require_http_methods
1311

1412
from airlock import login_api
15-
from airlock.api import Status
13+
from airlock.api import Status, UrlPath
1614
from airlock.file_browser_api import PathItem
1715
from airlock.forms import AddFileForm
1816
from local_db.api import LocalDBProvider
@@ -132,9 +130,10 @@ def use_tree_ui(request):
132130
def workspace_view(request, workspace_name: str, path: str = ""):
133131
workspace = validate_workspace(request.user, workspace_name)
134132

135-
relpath = Path(path)
136-
root = PathItem(workspace, Path("."), selected=relpath)
137-
path_item = PathItem(workspace, path, selected=relpath)
133+
relpath = UrlPath(path)
134+
workspace.selected_path = relpath
135+
root = PathItem(workspace)
136+
path_item = PathItem(workspace, relpath)
138137

139138
if not path_item.exists():
140139
raise Http404()
@@ -167,7 +166,7 @@ def workspace_view(request, workspace_name: str, path: str = ""):
167166
@require_http_methods(["POST"])
168167
def workspace_add_file_to_request(request, workspace_name):
169168
workspace = validate_workspace(request.user, workspace_name)
170-
relpath = Path(request.POST["path"])
169+
relpath = UrlPath(request.POST["path"])
171170
try:
172171
workspace.abspath(relpath)
173172
except api.FileNotFound:
@@ -178,7 +177,7 @@ def workspace_add_file_to_request(request, workspace_name):
178177
if not form.is_valid():
179178
for error in form.errors.values():
180179
messages.error(request, error)
181-
return redirect(workspace.get_url_for_path(relpath))
180+
return redirect(workspace.get_url(relpath))
182181

183182
group_name = request.POST.get("new_filegroup") or request.POST.get("filegroup")
184183
try:
@@ -192,7 +191,7 @@ def workspace_add_file_to_request(request, workspace_name):
192191
request, f"File has been added to request (file group '{group_name}')"
193192
)
194193
# redirect to this just added file
195-
return redirect(release_request.get_url_for_path(relpath))
194+
return redirect(release_request.get_url(relpath))
196195

197196

198197
def request_index(request):
@@ -215,9 +214,10 @@ def request_index(request):
215214
def request_view(request, request_id: str, path: str = ""):
216215
release_request = validate_release_request(request.user, request_id)
217216

218-
relpath = Path(path)
219-
root = PathItem(release_request, Path("."), selected=relpath)
220-
path_item = PathItem(release_request, path, selected=relpath)
217+
relpath = UrlPath(path)
218+
release_request.selected_path = relpath
219+
root = PathItem(release_request)
220+
path_item = PathItem(release_request, relpath)
221221

222222
if not path_item.exists():
223223
raise Http404()
@@ -273,7 +273,7 @@ def request_submit(request, request_id):
273273
raise PermissionDenied(str(exc))
274274

275275
messages.success(request, "Request has been submitted")
276-
return redirect(release_request.get_absolute_url())
276+
return redirect(release_request.get_url())
277277

278278

279279
@require_http_methods(["POST"])
@@ -286,7 +286,7 @@ def request_reject(request, request_id):
286286
raise PermissionDenied(str(exc))
287287

288288
messages.error(request, "Request has been rejected")
289-
return redirect(release_request.get_absolute_url())
289+
return redirect(release_request.get_url())
290290

291291

292292
@require_http_methods(["POST"])
@@ -315,4 +315,4 @@ def request_release_files(request, request_id):
315315
raise
316316

317317
messages.success(request, "Files have been released to jobs.opensafely.org")
318-
return redirect(release_request.get_absolute_url())
318+
return redirect(release_request.get_url())

0 commit comments

Comments
 (0)