Skip to content

Commit d348b23

Browse files
committed
feat(organization): add organization support
1 parent cefaa2c commit d348b23

36 files changed

Lines changed: 4827 additions & 1653 deletions

rootfs/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ FROM registry.drycc.cc/drycc/base:${CODENAME} as build-app
44
ADD web /web
55
WORKDIR /web
66

7-
ENV NODE_VERSION="22"
7+
ENV NODE_VERSION="24"
88

99
RUN install-stack node $NODE_VERSION && . init-stack \
1010
&& npm install --global yarn \
@@ -16,7 +16,7 @@ FROM registry.drycc.cc/drycc/base:${CODENAME}
1616
ARG DRYCC_UID=1001 \
1717
DRYCC_GID=1001 \
1818
DRYCC_HOME_DIR=/workspace \
19-
PYTHON_VERSION="3.13"
19+
PYTHON_VERSION="3.14"
2020

2121
RUN groupadd drycc --gid ${DRYCC_GID} \
2222
&& useradd drycc -u ${DRYCC_UID} -g ${DRYCC_GID} -s /bin/bash -m -d ${DRYCC_HOME_DIR}

rootfs/Dockerfile.test

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
1+
ARG CODENAME
2+
FROM registry.drycc.cc/drycc/base:${CODENAME} as build-app
3+
4+
ADD web /web
5+
WORKDIR /web
6+
7+
ENV NODE_VERSION="24"
8+
9+
RUN install-stack node $NODE_VERSION && . init-stack \
10+
&& npm install --global yarn \
11+
&& yarn install \
12+
&& yarn build
13+
14+
115
ARG CODENAME
216
FROM registry.drycc.cc/drycc/base:${CODENAME}
317

4-
ARG DRYCC_HOME_DIR=/workspace \
5-
PYTHON_VERSION="3.13" \
6-
POSTGRES_VERSION="17.6" \
7-
GOSU_VERSION="1.18"
18+
ARG DRYCC_UID=1001 \
19+
DRYCC_GID=1001 \
20+
DRYCC_HOME_DIR=/workspace \
21+
PYTHON_VERSION="3.14" \
22+
VALKEY_VERSION="9.0.1" \
23+
POSTGRES_VERSION="18.1" \
24+
GOSU_VERSION="1.19"
25+
26+
27+
RUN groupadd drycc --gid ${DRYCC_GID} \
28+
&& useradd drycc -u ${DRYCC_UID} -g ${DRYCC_GID} -s /bin/bash -m -d ${DRYCC_HOME_DIR}
829

930
ENV PGDATA="/var/lib/postgresql/data"
1031

@@ -16,6 +37,7 @@ RUN buildDeps='gcc rustc cargo libffi-dev musl-dev libldap2-dev libsasl2-dev'; \
1637
&& curl -SsL https://cli.codecov.io/latest/$([ $(dpkg --print-architecture) == "arm64" ] && echo linux-arm64 || echo linux)/codecov -o /usr/local/bin/codecov \
1738
&& chmod +x /usr/local/bin/codecov \
1839
&& install-stack python $PYTHON_VERSION \
40+
&& install-stack valkey $VALKEY_VERSION \
1941
&& install-stack postgresql $POSTGRES_VERSION \
2042
&& install-stack gosu $GOSU_VERSION && . init-stack \
2143
&& python3 -m venv ${DRYCC_HOME_DIR}/.venv \
@@ -50,6 +72,9 @@ RUN buildDeps='gcc rustc cargo libffi-dev musl-dev libldap2-dev libsasl2-dev'; \
5072
&& gosu postgres initdb -D $PGDATA
5173

5274
COPY . ${DRYCC_HOME_DIR}
75+
COPY --chown=${DRYCC_UID}:${DRYCC_GID} . ${DRYCC_HOME_DIR}
76+
COPY --chown=${DRYCC_UID}:${DRYCC_GID} --from=build-app /web/dist ${DRYCC_HOME_DIR}/web/dist
77+
5378
WORKDIR ${DRYCC_HOME_DIR}
5479
CMD ["bin/boot"]
5580
EXPOSE 8000

rootfs/api/exceptions.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ def custom_exception_handler(exc, context):
4343

4444
# No response means DRF couldn't handle it
4545
# Output a generic 500 in a JSON format
46-
print(response)
4746
if response is None:
4847
logging.exception('Uncaught Exception', exc_info=exc)
4948
set_rollback()
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Generated by Django 5.2.8 on 2025-12-29 07:46
2+
3+
import api.validators
4+
import django.db.models.deletion
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('api', '0003_application_allowed_origins_and_more'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='Organization',
18+
fields=[
19+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('name', models.SlugField(max_length=255, unique=True, validators=[api.validators.OrganizationNameValidator()], verbose_name='organization name')),
21+
('email', models.EmailField(max_length=254, verbose_name='email address')),
22+
('created', models.DateTimeField(auto_now_add=True)),
23+
('updated', models.DateTimeField(auto_now=True)),
24+
],
25+
),
26+
migrations.CreateModel(
27+
name='OrganizationInvitation',
28+
fields=[
29+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
30+
('email', models.EmailField(max_length=254, verbose_name='email address')),
31+
('token', models.CharField(max_length=128, unique=True)),
32+
('created', models.DateTimeField(auto_now_add=True)),
33+
('accepted', models.BooleanField(default=False, verbose_name='accepted')),
34+
('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
35+
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.organization')),
36+
],
37+
options={
38+
'unique_together': {('email', 'organization')},
39+
},
40+
),
41+
migrations.CreateModel(
42+
name='OrganizationMember',
43+
fields=[
44+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
45+
('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member')], max_length=50)),
46+
('created', models.DateTimeField(auto_now_add=True)),
47+
('updated', models.DateTimeField(auto_now=True)),
48+
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.organization')),
49+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
50+
],
51+
options={
52+
'unique_together': {('user', 'organization')},
53+
},
54+
),
55+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.8 on 2026-01-14 09:02
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api', '0004_organization_organizationinvitation_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='organizationmember',
15+
name='alerts',
16+
field=models.BooleanField(default=True),
17+
),
18+
]

rootfs/api/models.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.contrib.auth.models import AbstractUser
33
from django.utils.translation import gettext_lazy as _
44
from oauth2_provider.models import AbstractApplication
5-
from .validators import UsernameValidator
5+
from .validators import UsernameValidator, OrganizationNameValidator
66

77

88
class User(AbstractUser):
@@ -16,3 +16,67 @@ def allows_grant_type(self, *grant_types):
1616
return self.GRANT_AUTHORIZATION_CODE in grant_types or super().allows_grant_type(
1717
*grant_types
1818
)
19+
20+
21+
class Organization(models.Model):
22+
name = models.SlugField(
23+
_("organization name"),
24+
max_length=255,
25+
unique=True,
26+
validators=[OrganizationNameValidator()],
27+
)
28+
email = models.EmailField(_("email address"))
29+
created = models.DateTimeField(auto_now_add=True)
30+
updated = models.DateTimeField(auto_now=True)
31+
32+
def __str__(self):
33+
return self.name
34+
35+
36+
class OrganizationMember(models.Model):
37+
role_choices = [
38+
('admin', 'Admin'),
39+
('member', 'Member'),
40+
]
41+
42+
user = models.ForeignKey(User, on_delete=models.CASCADE)
43+
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
44+
role = models.CharField(max_length=50, choices=role_choices)
45+
alerts = models.BooleanField(default=True)
46+
created = models.DateTimeField(auto_now_add=True)
47+
updated = models.DateTimeField(auto_now=True)
48+
49+
class Meta:
50+
unique_together = ('user', 'organization')
51+
52+
def __str__(self):
53+
return f"{self.user.username} - {self.organization.name} ({self.role})"
54+
55+
56+
class OrganizationInvitation(models.Model):
57+
email = models.EmailField(_("email address"))
58+
token = models.CharField(max_length=128, unique=True)
59+
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
60+
inviter = models.ForeignKey(User, on_delete=models.CASCADE)
61+
created = models.DateTimeField(auto_now_add=True)
62+
accepted = models.BooleanField(_("accepted"), default=False)
63+
64+
class Meta:
65+
unique_together = ('email', 'organization')
66+
67+
def accept(self):
68+
user = User.objects.filter(email=self.email).first()
69+
if not user or self.accepted:
70+
return
71+
OrganizationMember.objects.get_or_create(
72+
user=user, organization=self.organization, defaults={'role': 'member'})
73+
self.accepted = True
74+
self.save()
75+
76+
@classmethod
77+
def bulk_accept_by_email(cls, email):
78+
for invitation in OrganizationInvitation.objects.filter(email=email, accepted=False):
79+
invitation.accept()
80+
81+
def __str__(self):
82+
return f"Invitation for {self.email} to join {self.organization.name}"

rootfs/api/serializers.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from rest_framework import serializers
1010

1111
from api.utils import timestamp2datetime
12+
from api.validators import OrganizationNameValidator
13+
from api.models import Organization, OrganizationMember, OrganizationInvitation
1214

1315
User = get_user_model()
1416
logger = logging.getLogger(__name__)
@@ -74,3 +76,38 @@ class Meta:
7476
fields = '__all__'
7577
read_only_fields = ['action_time', 'user', 'content_type', 'object_id',
7678
'object_repr', 'action_flag', 'change_message']
79+
80+
81+
class OrganizationSerializer(serializers.ModelSerializer):
82+
"""Serialize Organization model."""
83+
84+
class Meta:
85+
model = Organization
86+
fields = '__all__'
87+
read_only_fields = ['id', 'created', 'updated']
88+
extra_kwargs = {
89+
'name': {'validators': [OrganizationNameValidator()]}
90+
}
91+
92+
93+
class OrganizationMemberSerializer(serializers.ModelSerializer):
94+
"""Serialize OrganizationMember model."""
95+
user = serializers.ReadOnlyField(source='user.username')
96+
email = serializers.ReadOnlyField(source='user.email')
97+
organization = serializers.ReadOnlyField(source='organization.name')
98+
99+
class Meta:
100+
model = OrganizationMember
101+
fields = '__all__'
102+
read_only_fields = ['id', 'created', 'updated']
103+
104+
105+
class OrganizationInvitationSerializer(serializers.ModelSerializer):
106+
"""Serialize OrganizationInvitation model."""
107+
inviter = serializers.ReadOnlyField(source='inviter.username')
108+
organization = serializers.ReadOnlyField(source='organization.name')
109+
110+
class Meta:
111+
model = OrganizationInvitation
112+
fields = '__all__'
113+
read_only_fields = ['id', 'token', 'created']

rootfs/api/settings/production.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@
168168
SECURE_BROWSER_XSS_FILTER = True
169169
CSRF_COOKIE_SECURE = os.environ.get('CSRF_COOKIE_SECURE', 'false').lower() == "true"
170170
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == "true"
171+
CSRF_TRUSTED_ORIGINS = [r for r in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if r]
171172

172173
# Honor HTTPS from a trusted proxy
173174
# see https://docs.djangoproject.com/en/2.2/ref/settings/#secure-proxy-ssl-header
@@ -274,7 +275,7 @@
274275
SECRET_KEY = os.environ.get('DRYCC_SECRET_KEY', random_secret)
275276

276277
# database default setting
277-
DRYCC_DATABASE_URL = os.environ.get('DRYCC_DATABASE_URL', 'postgres://postgres:123456@49.232.207.93:5432/drycc_passport') # noqa
278+
DRYCC_DATABASE_URL = os.environ.get('DRYCC_DATABASE_URL', 'postgres://postgres:@:5432/passport')
278279
DATABASES = {
279280
'default': dj_database_url.config(default=DRYCC_DATABASE_URL)
280281
}
@@ -325,9 +326,6 @@
325326
"DEFAULT_CODE_CHALLENGE_METHOD": 'S256',
326327
}
327328
OAUTH2_PROVIDER_APPLICATION_MODEL = 'api.Application'
328-
# Valkey Configuration
329-
DRYCC_VALKEY_ADDRS = os.environ.get('DRYCC_VALKEY_ADDRS', '127.0.0.1:6379').split(",")
330-
DRYCC_VALKEY_PASSWORD = os.environ.get('DRYCC_VALKEY_PASSWORD', '')
331329

332330
# Cache Valkey Configuration
333331
CACHES = {
@@ -402,6 +400,9 @@
402400
# username regex
403401
USERNAME_REGEX = os.environ.get('USERNAME_REGEX', '^[a-z][a-z0-9]{4,}$')
404402

403+
# organization name regex
404+
ORGANIZATION_NAME_REGEX = os.environ.get('ORGANIZATION_NAME_REGEX', '^[0-9a-z]{5,255}$')
405+
405406
# reserved username
406407
RESERVED_USERNAMES_PATH = os.environ.get(
407408
'RESERVED_USERNAMES_PATH', '/etc/drycc/passport/reserved-usernames.txt')

rootfs/api/static/icons/logo.svg

Lines changed: 14 additions & 0 deletions
Loading
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% autoescape off %}
2+
Hi there,
3+
4+
Please click on the link below to confirm joining {{ invitation.organization.name }} organization:
5+
6+
{{ domain }}{% url 'organization_invitation_detail' name=invitation.organization.name uid=invitation.token %}
7+
{% endautoescape %}

0 commit comments

Comments
 (0)