Skip to content

Commit 5e2e71c

Browse files
authored
request-n-org (#364)
1 parent 0aec5bb commit 5e2e71c

13 files changed

Lines changed: 707 additions & 5 deletions

File tree

cabotage/client/templates/_base.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@
260260
<a href="{{ url_for('user.guide') }}"
261261
class="btn btn-ghost btn-sm text-base-content/60 hover:text-base-content text-xs font-medium">Guide</a>
262262
{% if current_user.admin %}
263+
{% if config.ORGANIZATION_REQUESTS_ENABLED %}
264+
<a href="{{ url_for('user.organization_requests') }}"
265+
class="btn btn-ghost btn-sm text-base-content/60 hover:text-base-content text-xs font-medium">Requests</a>
266+
{% endif %}
263267
<a href="{{ url_for('user.infra_observe') }}"
264268
class="btn btn-ghost btn-sm text-base-content/60 hover:text-base-content text-xs font-medium">Infra</a>
265269
{% endif %}
@@ -616,6 +620,10 @@
616620
<a href="{{ url_for('user.guide') }}"
617621
class="btn btn-ghost btn-sm justify-start text-sm">Guide</a>
618622
{% if current_user.admin %}
623+
{% if config.ORGANIZATION_REQUESTS_ENABLED %}
624+
<a href="{{ url_for('user.organization_requests') }}"
625+
class="btn btn-ghost btn-sm justify-start text-sm">Requests</a>
626+
{% endif %}
619627
<a href="{{ url_for('user.infra_observe') }}"
620628
class="btn btn-ghost btn-sm justify-start text-sm">Infrastructure</a>
621629
{% endif %}

cabotage/client/templates/main/home.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@
1313
<div class="empty-state py-16">
1414
<div class="text-base-content/15 mb-4">{{ icon('building', 'w-12 h-12') }}</div>
1515
<h1 class="text-xl font-semibold text-base-content mb-2">Welcome, {{ (current_user.username or current_user.email) | display_username }}</h1>
16-
<p class="text-sm text-base-content/40 max-w-sm">You're not a member of any organizations yet. An administrator will need to add you to one.</p>
16+
<p class="text-sm text-base-content/40 max-w-sm">{% if organization_requests_enabled %}You're not a member of any organizations yet. Request an organization to get started.{% else %}You're not a member of any organizations yet. An administrator will need to add you to one.{% endif %}</p>
17+
{% if organization_requests_enabled %}
18+
<a href="{{ url_for('user.organization_request_create') }}"
19+
class="btn btn-primary btn-sm gap-2 mt-4">
20+
{{ icon('plus', 'w-4 h-4') }}
21+
Request Organization
22+
</a>
23+
{% endif %}
1724
</div>
1825
</div>
1926
{% else %}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{% extends "_base.html" %}
2+
{% from "_macros.html" import render_field, breadcrumbs %}
3+
4+
{% block title %}Request Organization - Cabotage{% endblock %}
5+
6+
{% block breadcrumbs %}
7+
{{ breadcrumbs([
8+
{'label': 'Organizations', 'url': url_for('user.organizations')},
9+
{'label': 'Request'}
10+
]) }}
11+
{% endblock %}
12+
13+
{% block content %}
14+
<div class="form-card">
15+
<div class="card bg-base-200 border border-base-300 shadow-xl">
16+
<div class="card-body">
17+
<h1 class="text-xl font-semibold text-base-content mb-4">Request Organization</h1>
18+
19+
<form action="{{ url_for('user.organization_request_create') }}" method="POST" name="organization_request_form" class="flex flex-col gap-4">
20+
{{ organization_request_form.hidden_tag() }}
21+
{{ render_field(organization_request_form.name) }}
22+
{{ render_field(organization_request_form.slug) }}
23+
{{ render_field(organization_request_form.note) }}
24+
<div class="pt-2">
25+
<button type="submit" class="btn btn-primary w-full">Submit Request</button>
26+
</div>
27+
</form>
28+
</div>
29+
</div>
30+
</div>
31+
{% endblock %}
32+
33+
{% block js %}
34+
<script>
35+
applySlugify('#name', '#slug');
36+
</script>
37+
{% endblock %}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{% extends "_base.html" %}
2+
{% from "_macros.html" import page_header, empty_state %}
3+
4+
{% block title %}Organization Requests - Cabotage{% endblock %}
5+
6+
{% block content %}
7+
{% call page_header('Organization Requests', 'Review pending organization creation requests') %}
8+
{% endcall %}
9+
10+
{% if organization_requests %}
11+
<div class="overflow-x-auto border border-base-300 rounded-lg">
12+
<table class="table table-sm">
13+
<thead>
14+
<tr>
15+
<th>Organization</th>
16+
<th>Requester</th>
17+
<th>Status</th>
18+
<th>Requested</th>
19+
<th class="text-right">Actions</th>
20+
</tr>
21+
</thead>
22+
<tbody>
23+
{% for org_request in organization_requests %}
24+
<tr>
25+
<td>
26+
<div class="font-medium">{{ org_request.name }}</div>
27+
<div class="text-xs text-base-content/40">{{ org_request.slug }}</div>
28+
{% if org_request.note %}
29+
<div class="text-xs text-base-content/60 mt-1 max-w-md whitespace-normal">{{ org_request.note }}</div>
30+
{% endif %}
31+
</td>
32+
<td>
33+
<div>{{ (org_request.requester.username or org_request.requester.email) | display_username }}</div>
34+
<div class="text-xs text-base-content/40">{{ org_request.requester.email }}</div>
35+
</td>
36+
<td>
37+
<span class="badge badge-sm {% if org_request.status == 'approved' %}badge-success{% elif org_request.status == 'denied' %}badge-error{% else %}badge-neutral{% endif %}">{{ org_request.status }}</span>
38+
</td>
39+
<td>{{ org_request.created_at | humanize }}</td>
40+
<td class="text-right">
41+
{% if org_request.status == 'pending' %}
42+
<div class="flex justify-end gap-2">
43+
<form method="POST" action="{{ url_for('user.organization_request_approve', request_id=org_request.id) }}">
44+
{{ review_form.hidden_tag() }}
45+
<button type="submit" class="btn btn-primary btn-xs">Approve</button>
46+
</form>
47+
<form method="POST" action="{{ url_for('user.organization_request_deny', request_id=org_request.id) }}">
48+
{{ review_form.hidden_tag() }}
49+
<button type="submit" class="btn btn-ghost btn-xs">Deny</button>
50+
</form>
51+
</div>
52+
{% elif org_request.organization %}
53+
<a href="{{ url_for('user.organization', org_slug=org_request.organization.slug) }}" class="btn btn-ghost btn-xs">View Org</a>
54+
{% endif %}
55+
</td>
56+
</tr>
57+
{% endfor %}
58+
</tbody>
59+
</table>
60+
</div>
61+
{% else %}
62+
{{ empty_state('No organization requests', 'New requests will appear here.', icon_name='building') }}
63+
{% endif %}
64+
{% endblock %}

cabotage/client/templates/user/organizations.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
{{ icon('plus', 'w-4 h-4') }}
1515
New Organization
1616
</button>
17+
{% elif organization_requests_enabled %}
18+
<a href="{{ url_for('user.organization_request_create') }}"
19+
class="btn btn-primary btn-sm gap-2">
20+
{{ icon('plus', 'w-4 h-4') }}
21+
Request Organization
22+
</a>
1723
{% endif %}
1824
{% endcall %}
1925

@@ -49,6 +55,12 @@ <h3 class="text-base font-medium text-base-content/60 mb-1">No organizations yet
4955
{{ icon('plus', 'w-4 h-4') }}
5056
Create Organization
5157
</button>
58+
{% elif organization_requests_enabled %}
59+
<a href="{{ url_for('user.organization_request_create') }}"
60+
class="btn btn-primary btn-sm gap-2 mt-4">
61+
{{ icon('plus', 'w-4 h-4') }}
62+
Request Organization
63+
</a>
5264
{% endif %}
5365
</div>
5466
{% endif %}

cabotage/server/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ def server_error_page(error):
428428
return render_template("errors/500.html"), 500
429429

430430
from cabotage.server.models.admin import AdminModelView
431-
from cabotage.server.models.auth import Organization, Team
431+
from cabotage.server.models.auth import Organization, OrganizationRequest, Team
432432
from cabotage.server.models.projects import (
433433
Project,
434434
Application,
@@ -445,6 +445,8 @@ def server_error_page(error):
445445

446446
admin.add_view(AdminModelView(Role, db.session))
447447
admin.add_view(AdminModelView(Organization, db.session))
448+
if app.config.get("ORGANIZATION_REQUESTS_ENABLED", False):
449+
admin.add_view(AdminModelView(OrganizationRequest, db.session))
448450
admin.add_view(AdminModelView(Team, db.session))
449451
admin.add_view(AdminModelView(Project, db.session))
450452
admin.add_view(AdminModelView(Application, db.session))

cabotage/server/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ class Config(metaclass=MetaFlaskEnv):
133133
KUBERNETES_CONTEXT = "cabotage"
134134
KUBERNETES_BUILD_NAMESPACE = "cabotage-tenant-builds"
135135
NETWORK_POLICIES_ENABLED = False
136+
ORGANIZATION_REQUESTS_ENABLED = False
136137
BACKING_SERVICE_POSTGRES_ENABLED = False
137138
BACKING_SERVICE_REDIS_ENABLED = False
138139
BACKING_SERVICES_POOL = None

cabotage/server/main/views.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from flask import render_template, Blueprint
1+
from flask import current_app, render_template, Blueprint
22
from flask_login import current_user
33
from sqlalchemy import func
44

@@ -19,6 +19,9 @@ def home():
1919
project_count = 0
2020
app_count = 0
2121
deploy_count = 0
22+
organization_requests_enabled = current_app.config.get(
23+
"ORGANIZATION_REQUESTS_ENABLED", False
24+
)
2225
if current_user.is_authenticated:
2326
user_orgs = (
2427
db.session.query(OrganizationMember.organization_id)
@@ -60,6 +63,7 @@ def home():
6063
project_count=project_count,
6164
app_count=app_count,
6265
deploy_count=deploy_count,
66+
organization_requests_enabled=organization_requests_enabled,
6367
)
6468

6569

cabotage/server/models/auth.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,70 @@ def add_team(self, team):
448448
db.session.add(association)
449449

450450

451+
class OrganizationRequest(Model):
452+
__tablename__ = "organization_requests"
453+
454+
STATUS_PENDING = "pending"
455+
STATUS_APPROVED = "approved"
456+
STATUS_DENIED = "denied"
457+
458+
id: Mapped[uuid.UUID] = mapped_column(
459+
postgresql.UUID(as_uuid=True),
460+
server_default=text("gen_random_uuid()"),
461+
primary_key=True,
462+
)
463+
requester_user_id: Mapped[uuid.UUID] = mapped_column(
464+
postgresql.UUID(as_uuid=True),
465+
ForeignKey("users.id"),
466+
index=True,
467+
)
468+
reviewer_user_id: Mapped[uuid.UUID | None] = mapped_column(
469+
postgresql.UUID(as_uuid=True),
470+
ForeignKey("users.id"),
471+
index=True,
472+
)
473+
organization_id: Mapped[uuid.UUID | None] = mapped_column(
474+
postgresql.UUID(as_uuid=True),
475+
ForeignKey("organizations.id"),
476+
index=True,
477+
)
478+
name: Mapped[str] = mapped_column(Text())
479+
slug: Mapped[str] = mapped_column(postgresql.CITEXT(), index=True)
480+
note: Mapped[str | None] = mapped_column(Text())
481+
status: Mapped[str] = mapped_column(String(32), default=STATUS_PENDING, index=True)
482+
created_at: Mapped[datetime.datetime] = mapped_column(
483+
DateTime,
484+
default=lambda: datetime.datetime.now(datetime.timezone.utc).replace(
485+
tzinfo=None
486+
),
487+
index=True,
488+
)
489+
updated_at: Mapped[datetime.datetime] = mapped_column(
490+
DateTime,
491+
default=lambda: datetime.datetime.now(datetime.timezone.utc).replace(
492+
tzinfo=None
493+
),
494+
onupdate=lambda: datetime.datetime.now(datetime.timezone.utc).replace(
495+
tzinfo=None
496+
),
497+
)
498+
reviewed_at: Mapped[datetime.datetime | None] = mapped_column(DateTime)
499+
500+
requester: Mapped[User] = relationship(
501+
foreign_keys=[requester_user_id],
502+
backref=backref("organization_requests", lazy="dynamic"),
503+
)
504+
reviewer: Mapped[User | None] = relationship(foreign_keys=[reviewer_user_id])
505+
organization: Mapped[Organization | None] = relationship()
506+
507+
@property
508+
def is_pending(self):
509+
return self.status == self.STATUS_PENDING
510+
511+
def __repr__(self):
512+
return f"<OrganizationRequest {self.slug} {self.status}>"
513+
514+
451515
class Team(Model):
452516
__versioned__: dict = {}
453517
__tablename__ = "teams"

cabotage/server/user/forms.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
ValidationError,
2323
)
2424

25-
from cabotage.server.models.auth import Organization
25+
from cabotage.server.models.auth import Organization, OrganizationRequest
2626
from cabotage.server.models.projects import (
2727
Application,
2828
ApplicationEnvironment,
@@ -88,6 +88,45 @@ def validate_slug(form, field):
8888
return True
8989

9090

91+
class RequestOrganizationForm(FlaskForm):
92+
name = StringField(
93+
"Organization Name",
94+
[InputRequired()],
95+
description="Friendly and descriptive name for the organization.",
96+
)
97+
slug = StringField(
98+
"Organization Slug",
99+
[
100+
InputRequired(),
101+
Regexp("^[-a-z0-9]+$", message="Invalid Slug! Must match ^[-a-z0-9]+$"),
102+
],
103+
description="URL-safe short name for the organization.",
104+
)
105+
note = TextAreaField(
106+
"Note",
107+
[Optional(), Length(max=2000)],
108+
description="Optional context for the administrators reviewing this request.",
109+
)
110+
111+
def validate_slug(form, field):
112+
organization = Organization.query.filter_by(slug=field.data).first()
113+
if organization is not None:
114+
raise ValidationError("That organization slug is already in use.")
115+
org_request = OrganizationRequest.query.filter_by(
116+
slug=field.data,
117+
status=OrganizationRequest.STATUS_PENDING,
118+
).first()
119+
if org_request is not None:
120+
raise ValidationError(
121+
"That organization slug already has a pending request."
122+
)
123+
return True
124+
125+
126+
class ReviewOrganizationRequestForm(FlaskForm):
127+
pass
128+
129+
91130
class CreateProjectForm(FlaskForm):
92131
organization_id = SelectField(
93132
"Organization",

0 commit comments

Comments
 (0)