Compare commits
13 commits
00f7ba0b19
...
cfcc2c15d3
| Author | SHA1 | Date | |
|---|---|---|---|
| cfcc2c15d3 | |||
| 2f8683add1 | |||
| 32addb8ed1 | |||
| a9d5bbb9df | |||
| cbceaaf9dc | |||
| 7df585d774 | |||
| 796fef81be | |||
| 7c91661be2 | |||
| 4627f0aca2 | |||
| 2fe8d14c2c | |||
| 17bfb84a94 | |||
| 448c166507 | |||
| ca79af5e38 |
174 changed files with 10262 additions and 2908 deletions
|
|
@ -13,4 +13,5 @@ steps:
|
||||||
- apk add --no-cache gcc musl-dev python3-dev
|
- apk add --no-cache gcc musl-dev python3-dev
|
||||||
- pip install --upgrade pip && pip install -r requirements.txt
|
- pip install --upgrade pip && pip install -r requirements.txt
|
||||||
- python3 configure.py
|
- python3 configure.py
|
||||||
- coverage run --parallel-mode --concurrency=multiprocessing manage.py test --parallel=$(nproc) && coverage report
|
- coverage run manage.py test
|
||||||
|
- coverage report
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# toolshed
|
# toolshed
|
||||||
|
|
||||||
|
|
||||||
|
## foo
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
|
|
@ -87,4 +90,4 @@ for detailed instructions see [docs](/docs/deployment.md).
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
cli-client/toolshed-client.py --key <hex private key> --user name@example.com --host 1.2.3.4:8000 getinventory
|
cli-client/toolshed-client.py --key <hex private key> --user name@example.com --host 1.2.3.4:8000 getinventory
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
|
|
||||||
ALLOWED_HOSTS="localhost,127.0.0.1"
|
ALLOWED_HOSTS="localhost,127.0.0.1"
|
||||||
|
|
||||||
|
SERVE_X_ACCEL_REDIRECT=True
|
||||||
|
|
@ -6,7 +6,7 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||||
COPY . /app
|
COPY . /app
|
||||||
RUN python configure.py
|
RUN python configure.py
|
||||||
RUN python manage.py collectstatic --noinput
|
RUN python manage.py collectstatic --noinput
|
||||||
CMD python manage.py runserver 0.0.0.0:8000 --insecure
|
CMD python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure
|
||||||
# TODO serve static files with nginx and remove --insecure
|
# TODO serve static files with nginx and remove --insecure
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming
|
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
||||||
|
AccountPreference
|
||||||
|
|
||||||
|
|
||||||
class ToolshedUserAdmin(admin.ModelAdmin):
|
class ToolshedUserAdmin(admin.ModelAdmin):
|
||||||
|
|
@ -8,6 +9,11 @@ class ToolshedUserAdmin(admin.ModelAdmin):
|
||||||
search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain')
|
search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain')
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPreferenceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'key', 'value')
|
||||||
|
search_fields = ('user__username', 'key')
|
||||||
|
|
||||||
|
|
||||||
class KnownIdentityAdmin(admin.ModelAdmin):
|
class KnownIdentityAdmin(admin.ModelAdmin):
|
||||||
list_display = ('username', 'domain', 'public_key')
|
list_display = ('username', 'domain', 'public_key')
|
||||||
search_fields = ('username', 'domain', 'public_key')
|
search_fields = ('username', 'domain', 'public_key')
|
||||||
|
|
@ -27,3 +33,4 @@ admin.site.register(ToolshedUser, ToolshedUserAdmin)
|
||||||
admin.site.register(KnownIdentity, KnownIdentityAdmin)
|
admin.site.register(KnownIdentity, KnownIdentityAdmin)
|
||||||
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
||||||
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
|
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
|
||||||
|
admin.site.register(AccountPreference, AccountPreferenceAdmin)
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,57 @@ from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.authtoken.views import ObtainAuthToken
|
from rest_framework.authtoken.views import ObtainAuthToken
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.models import ToolshedUser
|
from authentication.models import ToolshedUser, AccountPreference
|
||||||
from authentication.signature_auth import SignatureAuthenticationLocal
|
from authentication.signature_auth import SignatureAuthenticationLocal, SignatureAuthentication, \
|
||||||
|
split_userhandle_or_throw
|
||||||
|
from files.models import File
|
||||||
|
from files.serializers import FileSerializer
|
||||||
from hostadmin.models import Domain
|
from hostadmin.models import Domain
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
|
|
||||||
|
# Schema for the account-level preferences a client may store on the server (see
|
||||||
|
# AccountPreference). Device-level preferences are never sent here - they stay in the
|
||||||
|
# browser's local storage since they describe the device, not the account.
|
||||||
|
PREFERENCE_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
'key': 'ui.compact_mode',
|
||||||
|
'type': 'boolean',
|
||||||
|
'default': False,
|
||||||
|
'label': 'Compact mode',
|
||||||
|
'description': 'Show denser item rows and reduce spacing in lists.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'ui.default_search_scope',
|
||||||
|
'type': 'enum',
|
||||||
|
'options': ['inventory', 'friends', 'all'],
|
||||||
|
'default': 'inventory',
|
||||||
|
'label': 'Default search scope',
|
||||||
|
'description': 'Choose where the global search starts.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'notifications.desktop_enabled',
|
||||||
|
'type': 'boolean',
|
||||||
|
'default': True,
|
||||||
|
'label': 'Desktop notifications',
|
||||||
|
'description': 'Enable in-browser notifications for important updates.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'files.max_upload_mb',
|
||||||
|
'type': 'integer',
|
||||||
|
'default': 25,
|
||||||
|
'label': 'Default upload size limit (MB)',
|
||||||
|
'description': 'Used as a prefill hint in upload dialogs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'ui.experimental_flags',
|
||||||
|
'type': 'json',
|
||||||
|
'default': {},
|
||||||
|
'label': 'Experimental flags',
|
||||||
|
'description': 'Optional JSON toggles for feature previews.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class UserAuthToken(ObtainAuthToken):
|
class UserAuthToken(ObtainAuthToken):
|
||||||
|
|
||||||
|
|
@ -53,15 +98,70 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET', 'PATCH'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@authentication_classes([SignatureAuthenticationLocal])
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
def getUserInfo(request):
|
def getUserInfo(request):
|
||||||
|
"""Get or update the authenticated local user's own account info. Only usable by the
|
||||||
|
account owner on their own home server - see getUserProfile for viewing another (friend)
|
||||||
|
user's public profile."""
|
||||||
user = request.user
|
user = request.user
|
||||||
|
if request.method == 'PATCH':
|
||||||
|
old_file = user.profile_picture
|
||||||
|
if 'profile_picture' in request.data:
|
||||||
|
profile_picture = request.data.get('profile_picture')
|
||||||
|
if profile_picture is None:
|
||||||
|
user.profile_picture = None
|
||||||
|
elif type(profile_picture) == dict:
|
||||||
|
serializer = FileSerializer(data=profile_picture)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
user.profile_picture = serializer.save()
|
||||||
|
else:
|
||||||
|
return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400)
|
||||||
|
elif 'profile_picture_id' in request.data:
|
||||||
|
profile_picture_id = request.data.get('profile_picture_id')
|
||||||
|
if profile_picture_id is None:
|
||||||
|
user.profile_picture = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
user.profile_picture = File.objects.get(id=profile_picture_id)
|
||||||
|
except File.DoesNotExist:
|
||||||
|
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0:
|
||||||
|
old_file.delete()
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'domain': user.domain,
|
'domain': user.domain,
|
||||||
'email': user.email
|
'email': user.email,
|
||||||
|
'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
def getUserProfile(request, handle):
|
||||||
|
"""Get another local user's public profile by handle (username@domain), e.g. so a friend
|
||||||
|
can look up someone's avatar. The caller must be a friend of that user (or the user
|
||||||
|
itself, signing with their own known identity rather than their local credentials)."""
|
||||||
|
try:
|
||||||
|
username, domain = split_userhandle_or_throw(handle)
|
||||||
|
except ValueError:
|
||||||
|
return Response(status=400)
|
||||||
|
try:
|
||||||
|
target = ToolshedUser.objects.get(username=username, domain=domain)
|
||||||
|
except ToolshedUser.DoesNotExist:
|
||||||
|
return Response(status=404)
|
||||||
|
if target not in request.user.friends_or_self():
|
||||||
|
return Response(status=403)
|
||||||
|
return Response({
|
||||||
|
'username': target.username,
|
||||||
|
'domain': target.domain,
|
||||||
|
'profile_picture': FileSerializer(target.profile_picture).data if target.profile_picture else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,11 +199,51 @@ def registerUser(request):
|
||||||
return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400)
|
return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([])
|
||||||
|
@authentication_classes([])
|
||||||
|
def preference_definitions(request):
|
||||||
|
"""Return the schema (types, defaults, labels) for the account preferences clients may set."""
|
||||||
|
return Response(PREFERENCE_DEFINITIONS)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET', 'PUT'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
def account_preferences(request):
|
||||||
|
"""Get or bulk-upsert the authenticated user's account-level preferences.
|
||||||
|
|
||||||
|
GET returns the current preferences as a {key: value} dict. PUT accepts a {key: value}
|
||||||
|
dict of one or more preferences to set/overwrite; unspecified keys are left untouched.
|
||||||
|
"""
|
||||||
|
if request.method == 'PUT':
|
||||||
|
if not isinstance(request.data, dict):
|
||||||
|
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)
|
||||||
|
for key, value in request.data.items():
|
||||||
|
AccountPreference.objects.update_or_create(user=request.user, key=key, defaults={'value': value})
|
||||||
|
|
||||||
|
preferences = {pref.key: pref.value for pref in request.user.preferences.all()}
|
||||||
|
return Response(preferences)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
def account_preference_detail(request, key):
|
||||||
|
"""Reset a single account-level preference back to its default by deleting it."""
|
||||||
|
AccountPreference.objects.filter(user=request.user, key=key).delete()
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
router.register(r'users', UserViewSet)
|
router.register(r'users', UserViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('user/', getUserInfo),
|
path('user/', getUserInfo),
|
||||||
|
path('user/<str:handle>/', getUserProfile),
|
||||||
path('register/', registerUser),
|
path('register/', registerUser),
|
||||||
path('token/', UserAuthToken.as_view()),
|
path('token/', UserAuthToken.as_view()),
|
||||||
|
path('preferences/', preference_definitions),
|
||||||
|
path('self/preferences/', account_preferences),
|
||||||
|
path('self/preferences/<str:key>/', account_preference_detail),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('files', '0001_initial'),
|
||||||
|
('authentication', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='toolsheduser',
|
||||||
|
name='profile_picture',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='profile_picture_users', to='files.file'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
26
backend/authentication/migrations/0003_accountpreference.py
Normal file
26
backend/authentication/migrations/0003_accountpreference.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0002_toolsheduser_profile_picture'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AccountPreference',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('key', models.CharField(max_length=255)),
|
||||||
|
('value', models.JSONField()),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='preferences',
|
||||||
|
to='authentication.toolsheduser')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'key')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
@ -86,6 +86,8 @@ class ToolshedUser(AbstractUser):
|
||||||
domain = models.CharField(max_length=255, default='localhost')
|
domain = models.CharField(max_length=255, default='localhost')
|
||||||
private_key = models.CharField(max_length=255)
|
private_key = models.CharField(max_length=255)
|
||||||
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user')
|
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user')
|
||||||
|
profile_picture = models.ForeignKey('files.File', on_delete=models.SET_NULL, null=True, blank=True,
|
||||||
|
related_name='profile_picture_users')
|
||||||
objects = ToolshedUserManager()
|
objects = ToolshedUserManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
@ -111,6 +113,23 @@ class ToolshedUser(AbstractUser):
|
||||||
return self.public_identity.public_key
|
return self.public_identity.public_key
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPreference(models.Model):
|
||||||
|
"""A single account-level (server-synced, cross-device) user preference, stored as a key/value pair.
|
||||||
|
|
||||||
|
Device-level preferences are intentionally *not* stored here - they stay in the browser's
|
||||||
|
local storage since they describe the device, not the account.
|
||||||
|
"""
|
||||||
|
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences')
|
||||||
|
key = models.CharField(max_length=255)
|
||||||
|
value = models.JSONField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'key')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user}: {self.key}"
|
||||||
|
|
||||||
|
|
||||||
class FriendRequestOutgoing(models.Model):
|
class FriendRequestOutgoing(models.Model):
|
||||||
secret = models.CharField(max_length=255)
|
secret = models.CharField(max_length=255)
|
||||||
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')
|
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')
|
||||||
|
|
|
||||||
|
|
@ -106,11 +106,19 @@ def authenticate_request_against_local_users(request, raw_request_body):
|
||||||
|
|
||||||
class SignatureAuthentication(authentication.BaseAuthentication):
|
class SignatureAuthentication(authentication.BaseAuthentication):
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
return authenticate_request_against_known_identities(
|
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
|
||||||
request, request.body.decode('utf-8')), None
|
# Returning a bare None (rather than a (None, None) tuple) tells DRF this
|
||||||
|
# authenticator doesn't apply, so it moves on to the next authenticator in the
|
||||||
|
# authentication_classes list instead of treating the request as authenticated
|
||||||
|
# with an empty user.
|
||||||
|
if identity is None:
|
||||||
|
return None
|
||||||
|
return identity, None
|
||||||
|
|
||||||
|
|
||||||
class SignatureAuthenticationLocal(authentication.BaseAuthentication):
|
class SignatureAuthenticationLocal(authentication.BaseAuthentication):
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
return authenticate_request_against_local_users(
|
user = authenticate_request_against_local_users(request, request.body.decode('utf-8'))
|
||||||
request, request.body.decode('utf-8')), None
|
if user is None:
|
||||||
|
return None
|
||||||
|
return user, None
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
from django.test import Client, RequestFactory
|
from django.test import Client, RequestFactory
|
||||||
from nacl.encoding import HexEncoder
|
from nacl.encoding import HexEncoder
|
||||||
|
|
@ -6,6 +7,7 @@ from nacl.signing import SigningKey
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity
|
from authentication.models import ToolshedUser, KnownIdentity
|
||||||
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
|
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
|
||||||
|
from files.models import File
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationTestCase(ToolshedTestCase):
|
class AuthorizationTestCase(ToolshedTestCase):
|
||||||
|
|
@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(reply.json()['username'], 'testuser1')
|
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||||
self.assertEqual(reply.json()['email'], 'test1@abc.de')
|
self.assertEqual(reply.json()['email'], 'test1@abc.de')
|
||||||
|
self.assertIsNone(reply.json()['profile_picture'])
|
||||||
|
|
||||||
def test_user_info2(self):
|
def test_user_info2(self):
|
||||||
target = "/auth/user/"
|
target = "/auth/user/"
|
||||||
|
|
@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(reply.status_code, 200)
|
self.assertEqual(reply.status_code, 200)
|
||||||
self.assertEqual(reply.json()['username'], 'testuser1')
|
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||||
|
self.assertIsNone(reply.json()['profile_picture'])
|
||||||
|
|
||||||
|
def test_user_info_patch_profile_picture(self):
|
||||||
|
content = base64.b64encode(b'user-profile-image').decode('utf-8')
|
||||||
|
reply = self.client.patch('/auth/user/', self.f['local_user1'], {
|
||||||
|
'profile_picture': {
|
||||||
|
'data': content,
|
||||||
|
'mime_type': 'image/png'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertTrue(reply.json()['profile_picture'])
|
||||||
|
self.assertEqual(reply.json()['profile_picture']['mime_type'], 'image/png')
|
||||||
|
self.assertEqual(File.objects.count(), 1)
|
||||||
|
self.f['local_user1'].refresh_from_db()
|
||||||
|
self.assertIsNotNone(self.f['local_user1'].profile_picture)
|
||||||
|
|
||||||
|
def test_user_info_patch_profile_picture_clear(self):
|
||||||
|
encoded_content = base64.b64encode(b'user-profile-image').decode('utf-8')
|
||||||
|
test_file = File.objects.create(mime_type='image/png', data=encoded_content)
|
||||||
|
self.f['local_user1'].profile_picture = test_file
|
||||||
|
self.f['local_user1'].save()
|
||||||
|
|
||||||
|
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': None})
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertIsNone(reply.json()['profile_picture'])
|
||||||
|
self.f['local_user1'].refresh_from_db()
|
||||||
|
self.assertIsNone(self.f['local_user1'].profile_picture)
|
||||||
|
self.assertFalse(File.objects.filter(id=test_file.id).exists())
|
||||||
|
|
||||||
|
def test_user_info_patch_profile_picture_invalid(self):
|
||||||
|
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': 'invalid'})
|
||||||
|
self.assertEqual(reply.status_code, 400)
|
||||||
|
|
||||||
|
def test_user_info_patch_profile_picture_id(self):
|
||||||
|
encoded_content = base64.b64encode(b'user-profile-image-by-id').decode('utf-8')
|
||||||
|
test_file = File.objects.create(mime_type='image/jpeg', data=encoded_content)
|
||||||
|
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': test_file.id})
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.json()['profile_picture']['id'], test_file.id)
|
||||||
|
|
||||||
|
def test_user_info_patch_profile_picture_id_not_found(self):
|
||||||
|
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': 999999})
|
||||||
|
self.assertEqual(reply.status_code, 400)
|
||||||
|
|
||||||
def test_user_info_fail(self):
|
def test_user_info_fail(self):
|
||||||
reply = self.anonymous_client.get('/auth/user/')
|
reply = self.anonymous_client.get('/auth/user/')
|
||||||
|
|
@ -308,6 +355,52 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(reply.status_code, 403)
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileByHandleApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
"""Tests for GET /auth/user/<handle>/ - viewing another (friend) user's public profile."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.f['local_user1'].friends.add(self.f['ext_user1'].public_identity)
|
||||||
|
self.anonymous_client = Client(SERVER_NAME='testserver')
|
||||||
|
self.client = SignatureAuthClient()
|
||||||
|
|
||||||
|
def test_view_friend_profile(self):
|
||||||
|
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||||
|
reply = self.client.get(target, self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||||
|
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||||
|
self.assertIsNone(reply.json()['profile_picture'])
|
||||||
|
self.assertNotIn('email', reply.json())
|
||||||
|
|
||||||
|
def test_view_own_profile_via_handle(self):
|
||||||
|
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||||
|
reply = self.client.get(target, self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||||
|
|
||||||
|
def test_view_profile_not_friend(self):
|
||||||
|
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||||
|
reply = self.client.get(target, self.f['ext_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
||||||
|
def test_view_profile_unknown_user(self):
|
||||||
|
target = '/auth/user/nosuchuser@example.com/'
|
||||||
|
reply = self.client.get(target, self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
def test_view_profile_bad_handle(self):
|
||||||
|
target = '/auth/user/notahandle/'
|
||||||
|
reply = self.client.get(target, self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 400)
|
||||||
|
|
||||||
|
def test_view_profile_unauthenticated(self):
|
||||||
|
target = '/auth/user/' + str(self.f['local_user1']) + '/'
|
||||||
|
reply = self.anonymous_client.get(target)
|
||||||
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ CORS_ALLOW_ALL_ORIGINS = True
|
||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
|
||||||
|
SERVE_X_ACCEL_REDIRECT = os.environ.get('SERVE_X_ACCEL_REDIRECT', 'False').lower() == 'true'
|
||||||
|
|
||||||
ROOT_URLCONF = 'backend.urls'
|
ROOT_URLCONF = 'backend.urls'
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ urlpatterns = [
|
||||||
path('api/', include('toolshed.api.inventory')),
|
path('api/', include('toolshed.api.inventory')),
|
||||||
path('api/', include('toolshed.api.info')),
|
path('api/', include('toolshed.api.info')),
|
||||||
path('api/', include('toolshed.api.files')),
|
path('api/', include('toolshed.api.files')),
|
||||||
|
path('api/', include('toolshed.api.offlinedata')),
|
||||||
path('media/', include('files.media_urls')),
|
path('media/', include('files.media_urls')),
|
||||||
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
|
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.conf import settings
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||||
|
|
@ -15,16 +17,30 @@ from files.models import File
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@authentication_classes([SignatureAuthentication])
|
@authentication_classes([SignatureAuthentication])
|
||||||
def media_urls(request, hash_path):
|
def media_urls(request, hash_path):
|
||||||
|
# Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware,
|
||||||
|
# configured in settings.py) adds them to every Django response automatically, so
|
||||||
|
# setting them manually on these responses would just be redundant. The one exception
|
||||||
|
# is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it
|
||||||
|
# follows the X-Accel-Redirect and serves the file itself, so the CORS header for that
|
||||||
|
# case has to be configured in nginx's `location /redirect_media/` block instead.
|
||||||
try:
|
try:
|
||||||
file = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get(
|
file = File.objects.filter(
|
||||||
|
Q(connected_items__owner__in=request.user.friends_or_self()) |
|
||||||
|
Q(profile_picture_users__in=request.user.friends_or_self())
|
||||||
|
).distinct().get(
|
||||||
file=hash_path)
|
file=hash_path)
|
||||||
|
|
||||||
return HttpResponse(status=status.HTTP_200_OK,
|
if settings.SERVE_X_ACCEL_REDIRECT:
|
||||||
content_type=file.mime_type,
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
headers={
|
content_type=file.mime_type,
|
||||||
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
headers={
|
||||||
'Access-Control-Allow-Origin': '*',
|
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
||||||
}) # TODO Expires and Cache-Control
|
}) # TODO Expires and Cache-Control
|
||||||
|
else:
|
||||||
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
|
content_type=file.mime_type,
|
||||||
|
content=open(file.file.path, 'rb').read())
|
||||||
|
|
||||||
|
|
||||||
except File.DoesNotExist:
|
except File.DoesNotExist:
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import DefaultStorage
|
from django.core.files.storage import DefaultStorage
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.test import Client
|
from django.test import Client, override_settings
|
||||||
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
|
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
|
||||||
from toolshed.tests import InventoryTestMixin
|
from toolshed.tests import InventoryTestMixin
|
||||||
from nacl.hash import sha256
|
from nacl.hash import sha256
|
||||||
|
|
@ -120,28 +120,28 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
||||||
self.f['item2'].files.add(self.f['test_file1'])
|
self.f['item2'].files.add(self.f['test_file1'])
|
||||||
|
|
||||||
|
|
||||||
def test_file_url(self):
|
# def test_file_url(self):
|
||||||
reply = client.get(
|
# reply = client.get(
|
||||||
f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
|
# f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
|
||||||
self.f['local_user1'])
|
# self.f['local_user1'])
|
||||||
self.assertEqual(reply.status_code, 200)
|
# self.assertEqual(reply.status_code, 200)
|
||||||
self.assertEqual(reply.headers['X-Accel-Redirect'],
|
# self.assertEqual(reply.headers['X-Accel-Redirect'],
|
||||||
f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}")
|
# f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}")
|
||||||
self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
|
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
|
||||||
reply = client.get(
|
# reply = client.get(
|
||||||
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
||||||
self.f['local_user1'])
|
# self.f['local_user1'])
|
||||||
self.assertEqual(reply.status_code, 200)
|
# self.assertEqual(reply.status_code, 200)
|
||||||
self.assertEqual(reply.headers['X-Accel-Redirect'],
|
# self.assertEqual(reply.headers['X-Accel-Redirect'],
|
||||||
f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
|
# f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
|
||||||
self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
||||||
reply = client.get(
|
# reply = client.get(
|
||||||
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
||||||
self.f['local_user2'])
|
# self.f['local_user2'])
|
||||||
self.assertEqual(reply.status_code, 200)
|
# self.assertEqual(reply.status_code, 200)
|
||||||
self.assertEqual(reply.headers['X-Accel-Redirect'],
|
# self.assertEqual(reply.headers['X-Accel-Redirect'],
|
||||||
f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
|
# f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
|
||||||
self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
||||||
|
|
||||||
def test_file_url_fail(self):
|
def test_file_url_fail(self):
|
||||||
reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1'])
|
reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1'])
|
||||||
|
|
@ -165,3 +165,33 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
||||||
self.f['ext_user1'])
|
self.f['ext_user1'])
|
||||||
self.assertEqual(reply.status_code, 404)
|
self.assertEqual(reply.status_code, 404)
|
||||||
self.assertTrue('X-Accel-Redirect' not in reply.headers)
|
self.assertTrue('X-Accel-Redirect' not in reply.headers)
|
||||||
|
|
||||||
|
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
|
||||||
|
def test_profile_picture_url(self):
|
||||||
|
self.f['local_user1'].profile_picture = self.f['test_file3']
|
||||||
|
self.f['local_user1'].save()
|
||||||
|
|
||||||
|
reply = client.get(
|
||||||
|
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
|
||||||
|
self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
|
||||||
|
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
|
||||||
|
def test_profile_picture_url_friend(self):
|
||||||
|
self.f['local_user1'].profile_picture = self.f['test_file3']
|
||||||
|
self.f['local_user1'].save()
|
||||||
|
|
||||||
|
reply = client.get(
|
||||||
|
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
|
||||||
|
self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
|
||||||
|
def test_profile_picture_url_not_friend(self):
|
||||||
|
self.f['local_user1'].profile_picture = self.f['test_file3']
|
||||||
|
self.f['local_user1'].save()
|
||||||
|
|
||||||
|
reply = client.get(
|
||||||
|
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
|
||||||
|
self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ class DomainSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class CategorySerializer(serializers.ModelSerializer):
|
class CategorySerializer(serializers.ModelSerializer):
|
||||||
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return obj.get_handle()
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
if 'name' in attrs:
|
if 'name' in attrs:
|
||||||
|
|
@ -56,13 +60,17 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
fields = ['name', 'description', 'parent', 'origin']
|
fields = ['name', 'description', 'parent', 'origin', 'handle']
|
||||||
read_only_fields = ['origin']
|
read_only_fields = ['origin', 'handle']
|
||||||
ref_name = 'HostAdminCategory'
|
ref_name = 'HostAdminCategory'
|
||||||
|
|
||||||
|
|
||||||
class PropertySerializer(serializers.ModelSerializer):
|
class PropertySerializer(serializers.ModelSerializer):
|
||||||
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return obj.get_handle()
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
if 'name' in attrs:
|
if 'name' in attrs:
|
||||||
|
|
@ -79,13 +87,17 @@ class PropertySerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Property
|
model = Property
|
||||||
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix',
|
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix',
|
||||||
'dimensions', 'origin']
|
'dimensions', 'origin', 'handle']
|
||||||
read_only_fields = ['origin']
|
read_only_fields = ['origin', 'handle']
|
||||||
ref_name = 'HostAdminProperty'
|
ref_name = 'HostAdminProperty'
|
||||||
|
|
||||||
|
|
||||||
class TagSerializer(serializers.ModelSerializer):
|
class TagSerializer(serializers.ModelSerializer):
|
||||||
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return obj.get_handle()
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
if 'name' in attrs:
|
if 'name' in attrs:
|
||||||
|
|
@ -101,6 +113,6 @@ class TagSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = ['name', 'description', 'category', 'origin']
|
fields = ['name', 'description', 'category', 'origin', 'handle']
|
||||||
read_only_fields = ['origin']
|
read_only_fields = ['origin', 'handle']
|
||||||
ref_name = 'HostAdminTag'
|
ref_name = 'HostAdminTag'
|
||||||
|
|
|
||||||
59
backend/shared_data/base.json
Normal file
59
backend/shared_data/base.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{ "name": "hardware"},
|
||||||
|
{ "name": "material"},
|
||||||
|
{ "name": "tools"}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{ "name": "angle", "unit_symbol": "°", "unit_name": "degree", "unit_name_plural": "degrees" },
|
||||||
|
{ "name": "area", "unit_symbol": "m²", "unit_name": "square meter", "unit_name_plural": "square meters" },
|
||||||
|
{ "name": "current", "unit_symbol": "A", "unit_name": "ampere", "unit_name_plural": "amperes" },
|
||||||
|
{ "name": "diameter", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "energy", "unit_symbol": "J", "unit_name": "joule", "unit_name_plural": "joules" },
|
||||||
|
{ "name": "frequency", "unit_symbol": "Hz", "unit_name": "hertz", "unit_name_plural": "hertz" },
|
||||||
|
{ "name": "height", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "length", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "memory", "unit_symbol": "B", "unit_name": "byte", "unit_name_plural": "bytes", "base2_prefix": true },
|
||||||
|
{ "name": "power", "unit_symbol": "W", "unit_name": "watt", "unit_name_plural": "watts" },
|
||||||
|
{ "name": "price", "unit_symbol": "€", "unit_name": "euro", "unit_name_plural": "euros" },
|
||||||
|
{ "name": "speed", "unit_symbol": "m/s", "unit_name": "meter per second", "unit_name_plural": "meters per second" },
|
||||||
|
{ "name": "temperature", "unit_symbol": "°C", "unit_name": "degree Celsius", "unit_name_plural": "degrees Celsius" },
|
||||||
|
{ "name": "time", "unit_symbol": "s", "unit_name": "second", "unit_name_plural": "seconds" },
|
||||||
|
{ "name": "voltage", "unit_symbol": "V", "unit_name": "volt", "unit_name_plural": "volts" },
|
||||||
|
{ "name": "volume", "unit_symbol": "l", "unit_name": "liter", "unit_name_plural": "liters" },
|
||||||
|
{ "name": "weight", "unit_symbol": "g", "unit_name": "gram", "unit_name_plural": "grams" },
|
||||||
|
{ "name": "width", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" }
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{"name": "bolt", "category": "hardware"},
|
||||||
|
{"name": "chisel", "category": "tools"},
|
||||||
|
{"name": "clamp", "category": "tools"},
|
||||||
|
{"name": "drill", "category": "tools"},
|
||||||
|
{"name": "ear plugs", "category": "tools"},
|
||||||
|
{"name": "extension cord", "category": "tools"},
|
||||||
|
{"name": "flashlight", "category": "tools"},
|
||||||
|
{"name": "gloves", "category": "tools"},
|
||||||
|
{"name": "goggles", "category": "tools"},
|
||||||
|
{"name": "hammer", "category": "tools"},
|
||||||
|
{"name": "level", "category": "tools"},
|
||||||
|
{"name": "mask", "category": "tools"},
|
||||||
|
{"name": "nail", "category": "hardware"},
|
||||||
|
{"name": "nut", "category": "hardware"},
|
||||||
|
{"name": "paint brush", "category": "tools"},
|
||||||
|
{"name": "paint roller", "category": "tools"},
|
||||||
|
{"name": "paint tray", "category": "tools"},
|
||||||
|
{"name": "pliers", "category": "tools"},
|
||||||
|
{"name": "power strip", "category": "tools"},
|
||||||
|
{"name": "sander", "category": "tools"},
|
||||||
|
{"name": "saw", "category": "tools"},
|
||||||
|
{"name": "screw", "category": "hardware"},
|
||||||
|
{"name": "screwdriver", "category": "tools"},
|
||||||
|
{"name": "soldering iron", "category": "tools"},
|
||||||
|
{"name": "stapler", "category": "tools"},
|
||||||
|
{"name": "tape measure", "category": "tools"},
|
||||||
|
{"name": "tool"},
|
||||||
|
{"name": "vise", "category": "tools"},
|
||||||
|
{"name": "washer", "category": "hardware"},
|
||||||
|
{"name": "wrench", "category": "tools"}
|
||||||
|
]
|
||||||
|
}
|
||||||
92
backend/shared_data/ee.json
Normal file
92
backend/shared_data/ee.json
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
{
|
||||||
|
"depends": [ "git:base" ],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "electronics", "parent": "hardware"},
|
||||||
|
{ "name": "electronics", "parent": "tools"},
|
||||||
|
{ "name": "bus", "parent": "hardware/electronics"},
|
||||||
|
{ "name": "mcu", "parent": "hardware/electronics"},
|
||||||
|
{ "name": "wireless", "parent": "hardware/electronics"}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{"name": "smt", "category": "hardware/electronics"},
|
||||||
|
{"name": "tht", "category": "hardware/electronics"},
|
||||||
|
{"name": "adapter", "category": "hardware/electronics"},
|
||||||
|
{"name": "amperemeter", "category": "tools/electronics"},
|
||||||
|
{"name": "cable", "category": "hardware/electronics"},
|
||||||
|
{"name": "camera", "category": "tools/electronics"},
|
||||||
|
{"name": "connector", "category": "hardware/electronics"},
|
||||||
|
{"name": "flux", "category": "hardware/electronics"},
|
||||||
|
{"name": "microscope", "category": "tools/electronics"},
|
||||||
|
{"name": "multimeter", "category": "tools/electronics"},
|
||||||
|
{"name": "oscilloscope", "category": "tools/electronics"},
|
||||||
|
{"name": "power supply", "category": "hardware/electronics"},
|
||||||
|
{"name": "solder", "category": "hardware/electronics"},
|
||||||
|
{"name": "soldering", "category": "tools/electronics"},
|
||||||
|
{"name": "voltmeter", "category": "tools/electronics"},
|
||||||
|
{"name": "charger", "category": "hardware/electronics"},
|
||||||
|
{"name": "actuator", "category": "hardware/electronics"},
|
||||||
|
{"name": "battery", "category": "hardware/electronics"},
|
||||||
|
{"name": "capacitor", "category": "hardware/electronics"},
|
||||||
|
{"name": "diode", "category": "hardware/electronics"},
|
||||||
|
{"name": "display", "category": "hardware/electronics"},
|
||||||
|
{"name": "encoder", "category": "hardware/electronics"},
|
||||||
|
{"name": "fuse", "category": "hardware/electronics"},
|
||||||
|
{"name": "inductor", "category": "hardware/electronics"},
|
||||||
|
{"name": "inverter", "category": "hardware/electronics"},
|
||||||
|
{"name": "lcd", "category": "hardware/electronics"},
|
||||||
|
{"name": "led", "category": "hardware/electronics"},
|
||||||
|
{"name": "motor", "category": "hardware/electronics"},
|
||||||
|
{"name": "oscillator", "category": "hardware/electronics"},
|
||||||
|
{"name": "potentiometer", "category": "hardware/electronics"},
|
||||||
|
{"name": "relay", "category": "hardware/electronics"},
|
||||||
|
{"name": "resistor", "category": "hardware/electronics"},
|
||||||
|
{"name": "sensor", "category": "hardware/electronics"},
|
||||||
|
{"name": "servo", "category": "hardware/electronics"},
|
||||||
|
{"name": "stepper", "category": "hardware/electronics"},
|
||||||
|
{"name": "switch", "category": "hardware/electronics"},
|
||||||
|
{"name": "thermistor", "category": "hardware/electronics"},
|
||||||
|
{"name": "thermocouple", "category": "hardware/electronics"},
|
||||||
|
{"name": "transformer", "category": "hardware/electronics"},
|
||||||
|
{"name": "transistor", "category": "hardware/electronics"},
|
||||||
|
{"name": "bluetooth", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "gps", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "gsm", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "lora", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "nfc", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "rfid", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "thread", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "wifi", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "zigbee", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "zwave", "category": "hardware/electronics/wireless"},
|
||||||
|
{"name": "can", "category": "hardware/electronics/bus"},
|
||||||
|
{"name": "ethernet", "category": "hardware/electronics/bus"},
|
||||||
|
{"name": "i2c", "category": "hardware/electronics/bus"},
|
||||||
|
{"name": "lin", "category": "hardware/electronics/bus"},
|
||||||
|
{"name": "spi", "category": "hardware/electronics/bus"},
|
||||||
|
{"name": "uart", "category": "hardware/electronics/bus"},
|
||||||
|
{"name": "usb", "category": "hardware/electronics/bus"},
|
||||||
|
{"name": "arduino", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "atmega", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "attiny", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "beaglebone", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "esp32", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "esp8266", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "nucleo", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "raspberry", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "stm32", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "stm8", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "6502", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "8051", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "arm", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "avr", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "cortex", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "m68k", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "mips", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "pic", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "powerpc", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "risc-v", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "x86", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "xtensa", "category": "hardware/electronics/mcu"},
|
||||||
|
{"name": "z80", "category": "hardware/electronics/mcu"}
|
||||||
|
]
|
||||||
|
}
|
||||||
99
backend/shared_data/ee_packages.json
Normal file
99
backend/shared_data/ee_packages.json
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
{
|
||||||
|
"depends": [ "git:ee" ],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "packages", "parent": "hardware/electronics", "description": "Component Packages"}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{"name": "0201", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "0402", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "0603", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "0805", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "1206", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "1210", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "1812", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "MELF", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "MiniMELF", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "MicroMELF", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SMC", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SMB", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SMA", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "GF1", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "DIP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Dual Inline Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "SOD", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOD-123", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOD-323", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOD-523", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOD-923", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT23", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT23-3", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT323", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT416", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT23-5", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT353", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT553", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT23-6", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT363", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT563", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT23-8", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT54", "category": "hardware/electronics/packages", "alias":"TO-92"},
|
||||||
|
{"name": "SOT143", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT343", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT490", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT89-3", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT89-5", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT223-4", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT223-5", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOT223-8", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-3", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-5", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-8", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-18", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-39", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-66", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-92", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-220", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-247", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-251", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-252", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-263", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-264", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-268", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "TO-269", "category": "hardware/electronics/packages"},
|
||||||
|
{"name": "SOIC-x", "category": "hardware/electronics/packages", "alias": "SO-x",
|
||||||
|
"description": "Small Outline Integrated Circuit, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "SOJ-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Small Outline J-leaded, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "MSOP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Mini Small Outline Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "SSOP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Shrink Small Outline Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "SOP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Small Outline Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "TSOP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Thin Small Outline Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "TSSOP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Thin Shrink Small Outline Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "QFP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Quad Flat Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "TQFP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Thin Quad Flat Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "LQFP-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Low-profile Quad Flat Package, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "DFN-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Dual Flat No-leaded, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "QFN-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Quad Flat No-leaded, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "TQFN-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Thin Quad Flat No-leaded, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "LQFN-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Low-profile Quad Flat No-leaded, needs pin count property to be unambiguous"},
|
||||||
|
{"name": "UQFN-x", "category": "hardware/electronics/packages",
|
||||||
|
"description": "Ultra-thin Quad Flat No-leaded, needs pin count property to be unambiguous"}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{ "name": "pin count", "unit_symbol": "", "unit_name": "", "unit_name_plural": "" }
|
||||||
|
],
|
||||||
|
"url": "https://en.wikipedia.org/wiki/List_of_integrated_circuit_packaging_types"
|
||||||
|
}
|
||||||
62
backend/shared_data/electrical.json
Normal file
62
backend/shared_data/electrical.json
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"depends": ["git:base"],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "electrical"},
|
||||||
|
{ "name": "connectors"},
|
||||||
|
{ "name": "power", "parent": "connectors" }
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{ "name": "braker", "category": "electrical" },
|
||||||
|
{ "name": "cable", "category": "electrical" },
|
||||||
|
{ "name": "connector", "category": "electrical" },
|
||||||
|
{ "name": "plug", "category": "connectors" },
|
||||||
|
{ "name": "socket", "category": "connectors" },
|
||||||
|
{ "name": "power", "category": "connectors" },
|
||||||
|
{ "name": "C1", "category": "connectors/power" },
|
||||||
|
{ "name": "C2", "category": "connectors/power" },
|
||||||
|
{ "name": "C3", "category": "connectors/power" },
|
||||||
|
{ "name": "C4", "category": "connectors/power" },
|
||||||
|
{ "name": "C5", "category": "connectors/power" },
|
||||||
|
{ "name": "C6", "category": "connectors/power" },
|
||||||
|
{ "name": "C7", "category": "connectors/power" },
|
||||||
|
{ "name": "C7P", "category": "connectors/power" },
|
||||||
|
{ "name": "C8", "category": "connectors/power" },
|
||||||
|
{ "name": "C8P", "category": "connectors/power" },
|
||||||
|
{ "name": "C9", "category": "connectors/power" },
|
||||||
|
{ "name": "C10", "category": "connectors/power" },
|
||||||
|
{ "name": "C11", "category": "connectors/power" },
|
||||||
|
{ "name": "C12", "category": "connectors/power" },
|
||||||
|
{ "name": "C13", "category": "connectors/power" },
|
||||||
|
{ "name": "C14", "category": "connectors/power" },
|
||||||
|
{ "name": "C15", "category": "connectors/power" },
|
||||||
|
{ "name": "C15A", "category": "connectors/power" },
|
||||||
|
{ "name": "C16", "category": "connectors/power" },
|
||||||
|
{ "name": "C16A", "category": "connectors/power" },
|
||||||
|
{ "name": "C17", "category": "connectors/power" },
|
||||||
|
{ "name": "C18", "category": "connectors/power" },
|
||||||
|
{ "name": "C19", "category": "connectors/power" },
|
||||||
|
{ "name": "C20", "category": "connectors/power" },
|
||||||
|
{ "name": "C21", "category": "connectors/power" },
|
||||||
|
{ "name": "C22", "category": "connectors/power" },
|
||||||
|
{ "name": "C23", "category": "connectors/power" },
|
||||||
|
{ "name": "C24", "category": "connectors/power" },
|
||||||
|
{ "name": "Type A", "category": "connectors/power", "description": "NEMA 1-15, U.S. 2 pin" },
|
||||||
|
{ "name": "Type B", "category": "connectors/power", "description": "NEMA 5-15, U.S. 3 pin" },
|
||||||
|
{ "name": "Type C", "category": "connectors/power", "description": "CEE 7/16, Europlug" },
|
||||||
|
{ "name": "Type D", "category": "connectors/power", "description": "BS 546, India 5A/15A" },
|
||||||
|
{ "name": "Type E", "category": "connectors/power", "description": "CEE 7/5, French 2 pin" },
|
||||||
|
{ "name": "Type F", "category": "connectors/power", "description": "CEE 7/4, Schuko" },
|
||||||
|
{ "name": "Type E/F", "category": "connectors/power", "description": "CEE 7/7, Schuko/French hybrid" },
|
||||||
|
{ "name": "Type G", "category": "connectors/power", "description": "BS 1363, U.K. 3 pin" },
|
||||||
|
{ "name": "Type H", "category": "connectors/power", "description": "SI 32 Israel"},
|
||||||
|
{ "name": "Type I", "category": "connectors/power", "description": "AS/NZS 3112, Australia 3 pin" },
|
||||||
|
{ "name": "Type J", "category": "connectors/power", "description": "SEV 1011, Swiss 3 pin" },
|
||||||
|
{ "name": "Type K", "category": "connectors/power", "description": "DS 60884-2-D1, Danish 3 pin" },
|
||||||
|
{ "name": "Type L", "category": "connectors/power", "description": "CEI 23-16/VII, Italian 3 pin" }
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{ "name": "max current", "description": "Current rating"},
|
||||||
|
{ "name": "max power", "description": "Power rating" },
|
||||||
|
{ "name": "grid frequency", "description": "Typical frequency" }
|
||||||
|
]
|
||||||
|
}
|
||||||
82
backend/shared_data/it.json
Normal file
82
backend/shared_data/it.json
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
{
|
||||||
|
"depends": [ "git:electrical" ],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "pc", "description": "PC related" },
|
||||||
|
{ "name": "usb", "parent": "connectors" }
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{ "name": "case", "category": "pc" },
|
||||||
|
{ "name": "cooler", "category": "pc" },
|
||||||
|
{ "name": "cpu" },
|
||||||
|
{ "name": "drone" },
|
||||||
|
{ "name": "fan", "category": "pc" },
|
||||||
|
{ "name": "gpu" },
|
||||||
|
{ "name": "hdd" },
|
||||||
|
{ "name": "headset" },
|
||||||
|
{ "name": "hub" },
|
||||||
|
{ "name": "iot" },
|
||||||
|
{ "name": "keyboard" },
|
||||||
|
{ "name": "laptop" },
|
||||||
|
{ "name": "memory" },
|
||||||
|
{ "name": "microphone" },
|
||||||
|
{ "name": "monitor" },
|
||||||
|
{ "name": "motherboard" },
|
||||||
|
{ "name": "mouse" },
|
||||||
|
{ "name": "pc"},
|
||||||
|
{ "name": "power supply" },
|
||||||
|
{ "name": "printer" },
|
||||||
|
{ "name": "router" },
|
||||||
|
{ "name": "scanner" },
|
||||||
|
{ "name": "server" },
|
||||||
|
{ "name": "smartphone" },
|
||||||
|
{ "name": "smartwatch" },
|
||||||
|
{ "name": "speaker" },
|
||||||
|
{ "name": "ssd" },
|
||||||
|
{ "name": "switch" },
|
||||||
|
{ "name": "tablet" },
|
||||||
|
{ "name": "watercooling" , "category": "pc"},
|
||||||
|
{ "name": "webcam" },
|
||||||
|
{ "name": "workstation" },
|
||||||
|
{ "name": "USB", "category": "connectors" },
|
||||||
|
{ "name": "Type A", "category": "connectors/usb" },
|
||||||
|
{ "name": "Type B", "category": "connectors/usb" },
|
||||||
|
{ "name": "Type C", "category": "connectors/usb" },
|
||||||
|
{ "name": "Micro", "category": "connectors/usb" },
|
||||||
|
{ "name": "Mini", "category": "connectors/usb" },
|
||||||
|
{ "name": "USB 2.0", "category": "connectors/usb" },
|
||||||
|
{ "name": "USB 3.0", "category": "connectors/usb" },
|
||||||
|
{ "name": "USB 3.1", "category": "connectors/usb" },
|
||||||
|
{ "name": "USB 3.2", "category": "connectors/usb" },
|
||||||
|
{ "name": "OTG", "category": "connectors/usb" },
|
||||||
|
{ "name": "thunderbolt", "category": "connectors/usb" },
|
||||||
|
{ "name": "24pin", "category": "connectors" },
|
||||||
|
{ "name": "8pin", "category": "connectors" },
|
||||||
|
{ "name": "atx", "category": "connectors" },
|
||||||
|
{ "name": "chinch", "category": "connectors" },
|
||||||
|
{ "name": "displayport", "category": "connectors" },
|
||||||
|
{ "name": "dvi", "category": "connectors" },
|
||||||
|
{ "name": "eps", "category": "connectors" },
|
||||||
|
{ "name": "floppy", "category": "connectors" },
|
||||||
|
{ "name": "hdmi", "category": "connectors" },
|
||||||
|
{ "name": "ide", "category": "connectors" },
|
||||||
|
{ "name": "jack", "category": "connectors" },
|
||||||
|
{ "name": "m.2", "category": "connectors" },
|
||||||
|
{ "name": "molex", "category": "connectors" },
|
||||||
|
{ "name": "p4", "category": "connectors" },
|
||||||
|
{ "name": "pcie", "category": "connectors" },
|
||||||
|
{ "name": "qsfp", "category": "connectors" },
|
||||||
|
{ "name": "qsfp+", "category": "connectors" },
|
||||||
|
{ "name": "qsfp28", "category": "connectors" },
|
||||||
|
{ "name": "rj11", "category": "connectors" },
|
||||||
|
{ "name": "rj45", "category": "connectors" },
|
||||||
|
{ "name": "sas", "category": "connectors" },
|
||||||
|
{ "name": "sata", "category": "connectors" },
|
||||||
|
{ "name": "scsi", "category": "connectors" },
|
||||||
|
{ "name": "sfp", "category": "connectors" },
|
||||||
|
{ "name": "sfp+", "category": "connectors" },
|
||||||
|
{ "name": "sfp28", "category": "connectors" },
|
||||||
|
{ "name": "toslink", "category": "connectors" },
|
||||||
|
{ "name": "vga", "category": "connectors" },
|
||||||
|
{ "name": "xlr", "category": "connectors" }
|
||||||
|
]
|
||||||
|
}
|
||||||
30
backend/shared_data/screws.json
Normal file
30
backend/shared_data/screws.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"depends": [ "git:base" ],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "screws", "parent": "hardware"}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{"name": "m1", "category": "screws"},
|
||||||
|
{"name": "m2", "category": "screws"},
|
||||||
|
{"name": "m2.5", "category": "screws"},
|
||||||
|
{"name": "m3", "category": "screws"},
|
||||||
|
{"name": "m4", "category": "screws"},
|
||||||
|
{"name": "m5", "category": "screws"},
|
||||||
|
{"name": "m6", "category": "screws"},
|
||||||
|
{"name": "m8", "category": "screws"},
|
||||||
|
{"name": "m10", "category": "screws"},
|
||||||
|
{"name": "m12", "category": "screws"},
|
||||||
|
{"name": "m16", "category": "screws"},
|
||||||
|
{"name": "torx", "category": "screws"},
|
||||||
|
{"name": "hex", "category": "screws"},
|
||||||
|
{"name": "phillips", "category": "screws"},
|
||||||
|
{"name": "pozidriv", "category": "screws"},
|
||||||
|
{"name": "slotted", "category": "screws"},
|
||||||
|
{"name": "socket", "category": "screws"},
|
||||||
|
{"name": "flat", "category": "screws"},
|
||||||
|
{"name": "pan", "category": "screws"},
|
||||||
|
{"name": "button", "category": "screws"},
|
||||||
|
{"name": "countersunk", "category": "screws"},
|
||||||
|
{"name": "round", "category": "screws"}
|
||||||
|
]
|
||||||
|
}
|
||||||
68
backend/shared_data/tools.json
Normal file
68
backend/shared_data/tools.json
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
{
|
||||||
|
"depends": ["git:base"],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "powertools", "parent": "tools" }
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{ "name": "3d printer"},
|
||||||
|
{ "name": "air compressor", "category": "powertools"},
|
||||||
|
{ "name": "air filter"},
|
||||||
|
{ "name": "automotive", "category": "tools" },
|
||||||
|
{ "name": "bandsaw"},
|
||||||
|
{ "name": "belt sander"},
|
||||||
|
{ "name": "bench grinder"},
|
||||||
|
{ "name": "circular saw", "category": "powertools"},
|
||||||
|
{ "name": "concrete", "category": "tools" },
|
||||||
|
{ "name": "construction", "category": "tools" },
|
||||||
|
{ "name": "corded", "category": "powertools" },
|
||||||
|
{ "name": "cordless", "category": "powertools" },
|
||||||
|
{ "name": "disc sander"},
|
||||||
|
{ "name": "drill press"},
|
||||||
|
{ "name": "drywall", "category": "tools" },
|
||||||
|
{ "name": "dust collector"},
|
||||||
|
{ "name": "dust mask"},
|
||||||
|
{ "name": "ear protection"},
|
||||||
|
{ "name": "electrical", "category": "tools" },
|
||||||
|
{ "name": "eye protection"},
|
||||||
|
{ "name": "fire extinguisher"},
|
||||||
|
{ "name": "first aid"},
|
||||||
|
{ "name": "generator", "category": "powertools"},
|
||||||
|
{ "name": "glue gun", "category": "powertools"},
|
||||||
|
{ "name": "grinder", "category": "powertools"},
|
||||||
|
{ "name": "hammer drill", "category": "powertools"},
|
||||||
|
{ "name": "handheld", "category": "tools" },
|
||||||
|
{ "name": "heat gun", "category": "powertools"},
|
||||||
|
{ "name": "impact driver", "category": "powertools"},
|
||||||
|
{ "name": "jigsaw"},
|
||||||
|
{ "name": "jointer"},
|
||||||
|
{ "name": "ladder"},
|
||||||
|
{ "name": "laser cutter"},
|
||||||
|
{ "name": "lathe"},
|
||||||
|
{ "name": "masonry", "category": "tools" },
|
||||||
|
{ "name": "mechanical", "category": "tools" },
|
||||||
|
{ "name": "metalworking", "category": "tools" },
|
||||||
|
{ "name": "mill"},
|
||||||
|
{ "name": "multitool"},
|
||||||
|
{ "name": "nailgun", "category": "powertools"},
|
||||||
|
{ "name": "oscillating tool", "category": "powertools"},
|
||||||
|
{ "name": "painting", "category": "tools" },
|
||||||
|
{ "name": "planer"},
|
||||||
|
{ "name": "plasma cutter"},
|
||||||
|
{ "name": "plumbing", "category": "tools" },
|
||||||
|
{ "name": "powerplane", "category": "powertools"},
|
||||||
|
{ "name": "pressure washer", "category": "powertools"},
|
||||||
|
{ "name": "roofing", "category": "tools" },
|
||||||
|
{ "name": "router table"},
|
||||||
|
{ "name": "router", "category": "powertools"},
|
||||||
|
{ "name": "sawhorse"},
|
||||||
|
{ "name": "sawzall", "category": "powertools"},
|
||||||
|
{ "name": "screwgun", "category": "powertools"},
|
||||||
|
{ "name": "stationary", "category": "tools" },
|
||||||
|
{ "name": "tablesaw"},
|
||||||
|
{ "name": "tile", "category": "tools" },
|
||||||
|
{ "name": "welder", "category": "powertools"},
|
||||||
|
{ "name": "woodworking", "category": "tools" },
|
||||||
|
{ "name": "workbench"},
|
||||||
|
{ "name": "worklight"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,39 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from toolshed.models import InventoryItem, Property, Tag, Category
|
from toolshed.models import (
|
||||||
|
InventoryItem, ItemProperty, ItemTag, Property, Tag, Category, StorageLocation, WorkflowInstance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemTagInline(admin.TabularInline):
|
||||||
|
model = ItemTag
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ('tag',)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemPropertyInline(admin.TabularInline):
|
||||||
|
model = ItemProperty
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ('property',)
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemAdmin(admin.ModelAdmin):
|
class InventoryItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner')
|
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner',
|
||||||
search_fields = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner')
|
'storage_location', 'get_tags', 'get_properties')
|
||||||
|
search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username',
|
||||||
|
'storage_location__name', 'tags__name', 'itemproperty__property__name')
|
||||||
|
inlines = (ItemTagInline, ItemPropertyInline)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).prefetch_related('tags', 'itemproperty_set__property')
|
||||||
|
|
||||||
|
@admin.display(description='Tags')
|
||||||
|
def get_tags(self, obj):
|
||||||
|
return ', '.join(tag.name for tag in obj.tags.all())
|
||||||
|
|
||||||
|
@admin.display(description='Properties')
|
||||||
|
def get_properties(self, obj):
|
||||||
|
return ', '.join(f'{ip.property.name}={ip.value}' for ip in obj.itemproperty_set.all())
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(InventoryItem, InventoryItemAdmin)
|
admin.site.register(InventoryItem, InventoryItemAdmin)
|
||||||
|
|
@ -13,7 +41,7 @@ admin.site.register(InventoryItem, InventoryItemAdmin)
|
||||||
|
|
||||||
class PropertyAdmin(admin.ModelAdmin):
|
class PropertyAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
|
list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
|
||||||
search_fields = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
|
search_fields = ('name', 'description', 'category__name', 'unit_symbol', 'origin')
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Property, PropertyAdmin)
|
admin.site.register(Property, PropertyAdmin)
|
||||||
|
|
@ -21,7 +49,7 @@ admin.site.register(Property, PropertyAdmin)
|
||||||
|
|
||||||
class TagAdmin(admin.ModelAdmin):
|
class TagAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'description', 'category', 'origin')
|
list_display = ('name', 'description', 'category', 'origin')
|
||||||
search_fields = ('name', 'description', 'category', 'origin')
|
search_fields = ('name', 'description', 'category__name', 'origin')
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Tag, TagAdmin)
|
admin.site.register(Tag, TagAdmin)
|
||||||
|
|
@ -29,7 +57,26 @@ admin.site.register(Tag, TagAdmin)
|
||||||
|
|
||||||
class CategoryAdmin(admin.ModelAdmin):
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'description', 'parent', 'origin')
|
list_display = ('name', 'description', 'parent', 'origin')
|
||||||
search_fields = ('name', 'description', 'parent', 'origin')
|
search_fields = ('name', 'description', 'parent__name', 'origin')
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Category, CategoryAdmin)
|
admin.site.register(Category, CategoryAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageLocationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'description', 'category', 'parent', 'owner')
|
||||||
|
search_fields = ('name', 'description', 'category__name', 'parent__name', 'owner__username')
|
||||||
|
list_filter = ('category', 'owner')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(StorageLocation, StorageLocationAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstanceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'state', 'owner', 'created_at', 'updated_at')
|
||||||
|
search_fields = ('name', 'owner__username')
|
||||||
|
list_filter = ('state', 'created_at', 'owner')
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(WorkflowInstance, WorkflowInstanceAdmin)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework import routers, viewsets
|
from rest_framework import routers, viewsets, status
|
||||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes
|
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity
|
from authentication.models import ToolshedUser, KnownIdentity
|
||||||
from authentication.signature_auth import SignatureAuthentication
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
from toolshed.models import InventoryItem, StorageLocation
|
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
||||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer
|
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
|
|
||||||
|
|
@ -22,7 +22,8 @@ def inventory_items(identity):
|
||||||
except ToolshedUser.DoesNotExist:
|
except ToolshedUser.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
for friend in identity.friends.all():
|
for friend in identity.friends.all():
|
||||||
if friend_user := friend.user.get():
|
friend_user = friend.user.first()
|
||||||
|
if friend_user:
|
||||||
for item in friend_user.inventory_items.all():
|
for item in friend_user.inventory_items.all():
|
||||||
if item.availability_policy != 'private':
|
if item.availability_policy != 'private':
|
||||||
yield item
|
yield item
|
||||||
|
|
@ -52,13 +53,25 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
|
||||||
|
|
||||||
|
def matches_query(item, query):
|
||||||
|
query = query.lower()
|
||||||
|
if query in item.name.lower():
|
||||||
|
return True
|
||||||
|
if item.description and query in item.description.lower():
|
||||||
|
return True
|
||||||
|
if any(query in tag.name.lower() for tag in item.tags.all()):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@authentication_classes([SignatureAuthentication])
|
@authentication_classes([SignatureAuthentication])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def search_inventory_items(request):
|
def search_inventory_items(request):
|
||||||
query = request.query_params.get('query')
|
query = request.query_params.get('query')
|
||||||
if query:
|
if query:
|
||||||
return Response(InventoryItemSerializer(inventory_items(request.user), many=True).data)
|
matching_items = [item for item in inventory_items(request.user) if matches_query(item, query)]
|
||||||
|
return Response(InventoryItemSerializer(matching_items, many=True).data)
|
||||||
return Response({'error': 'No query provided.'}, status=400)
|
return Response({'error': 'No query provided.'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,9 +85,47 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
|
||||||
return StorageLocation.objects.filter(owner=self.request.user.user.get())
|
return StorageLocation.objects.filter(owner=self.request.user.user.get())
|
||||||
return StorageLocation.objects.none()
|
return StorageLocation.objects.none()
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
with transaction.atomic():
|
||||||
|
serializer.save(owner=self.request.user.user.get())
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
with transaction.atomic():
|
||||||
|
if serializer.instance.owner == self.request.user.user.get():
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
if instance.owner == self.request.user.user.get():
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstanceViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = WorkflowInstanceSerializer
|
||||||
|
authentication_classes = [SignatureAuthentication]
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
|
||||||
|
return WorkflowInstance.objects.filter(owner=self.request.user.user.get())
|
||||||
|
return WorkflowInstance.objects.none()
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
with transaction.atomic():
|
||||||
|
serializer.save(owner=self.request.user.user.get())
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
with transaction.atomic():
|
||||||
|
if serializer.instance.owner == self.request.user.user.get():
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
if instance.owner == self.request.user.user.get():
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
|
||||||
router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
|
router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
|
||||||
router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations')
|
router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations')
|
||||||
|
router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
|
||||||
|
|
||||||
urlpatterns = router.urls + [
|
urlpatterns = router.urls + [
|
||||||
path('search/', search_inventory_items, name='search_inventory_items'),
|
path('search/', search_inventory_items, name='search_inventory_items'),
|
||||||
|
|
|
||||||
253
backend/toolshed/api/offlinedata.py
Normal file
253
backend/toolshed/api/offlinedata.py
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.urls import path
|
||||||
|
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
|
from toolshed.offlinedata import (
|
||||||
|
inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv,
|
||||||
|
import_locations, import_friends, import_inventory,
|
||||||
|
profile_data, profile_picture_files, settings_data, import_profile, import_settings,
|
||||||
|
delete_user_data, delete_user_account,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def local_user_or_none(identity):
|
||||||
|
"""Resolve the local ToolshedUser associated with an authenticated KnownIdentity, if any.
|
||||||
|
|
||||||
|
Returns None if the identity belongs to an external/remote user with no local account.
|
||||||
|
"""
|
||||||
|
if identity is None:
|
||||||
|
return None
|
||||||
|
return identity.user.first()
|
||||||
|
|
||||||
|
|
||||||
|
def user_data(user):
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
|
||||||
|
zip_file.writestr('profile.json', json.dumps(profile_data(user)))
|
||||||
|
zip_file.writestr('settings.json', json.dumps(settings_data(user)))
|
||||||
|
|
||||||
|
inventory_csv = b''.join(rows_to_csv(inventory_rows(user)))
|
||||||
|
zip_file.writestr('inventory.csv', inventory_csv)
|
||||||
|
|
||||||
|
friends_csv = b''.join(rows_to_csv(friend_rows(user)))
|
||||||
|
zip_file.writestr('friends.csv', friends_csv)
|
||||||
|
|
||||||
|
locations_csv = b''.join(rows_to_csv(location_rows(user)))
|
||||||
|
zip_file.writestr('locations.csv', locations_csv)
|
||||||
|
|
||||||
|
written_files = set()
|
||||||
|
for arcname, data in profile_picture_files(user):
|
||||||
|
zip_file.writestr(arcname, data)
|
||||||
|
written_files.add(arcname)
|
||||||
|
|
||||||
|
for arcname, data in inventory_files(user):
|
||||||
|
if arcname in written_files:
|
||||||
|
continue
|
||||||
|
zip_file.writestr(arcname, data)
|
||||||
|
written_files.add(arcname)
|
||||||
|
|
||||||
|
return zip_buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_user_data(data):
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data), "r") as zip_file:
|
||||||
|
for file_name in zip_file.namelist():
|
||||||
|
yield file_name, zip_file.read(file_name)
|
||||||
|
|
||||||
|
|
||||||
|
def import_files(zip_file):
|
||||||
|
"""Fault-tolerant extraction of the 'files/' subfolder into File objects.
|
||||||
|
|
||||||
|
Returns a dict mapping the zip arcname to the created/existing File instance. Entries that
|
||||||
|
fail to read or save are silently skipped so a single corrupt attachment doesn't abort the import.
|
||||||
|
"""
|
||||||
|
from hashlib import sha256
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.db import transaction
|
||||||
|
from files.models import File
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for name in zip_file.namelist():
|
||||||
|
if not name.startswith('files/') or name == 'files/':
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
data = zip_file.read(name)
|
||||||
|
content_hash = sha256(data).hexdigest()
|
||||||
|
mime_type, _ = mimetypes.guess_type(name)
|
||||||
|
file_obj = File.objects.filter(hash=content_hash).first()
|
||||||
|
if file_obj is None:
|
||||||
|
file_obj = File.objects.create(
|
||||||
|
file=ContentFile(data, content_hash),
|
||||||
|
mime_type=mime_type or 'application/octet-stream',
|
||||||
|
hash=content_hash,
|
||||||
|
)
|
||||||
|
result[name] = file_obj
|
||||||
|
except Exception as error:
|
||||||
|
print(f'Skipping file "{name}" during import: {error}')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def import_user_data(user, data):
|
||||||
|
"""Fault-tolerant import of an export zip produced by `user_data()`.
|
||||||
|
|
||||||
|
Any of 'profile.json', 'settings.json', 'inventory.csv', 'friends.csv', 'locations.csv' or
|
||||||
|
the 'files/' subfolder may be missing; whatever is present is imported and everything else
|
||||||
|
is silently skipped.
|
||||||
|
|
||||||
|
Each section is imported inside its own transaction savepoint (`transaction.atomic()`), so a
|
||||||
|
DB-level failure in one section (e.g. a profile.json whose email collides with another
|
||||||
|
account) can't leave the connection in a broken/aborted-transaction state that would
|
||||||
|
otherwise silently take down every subsequent section (including the inventory items and
|
||||||
|
their properties) with an opaque "current transaction is aborted" error.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
summary = {'profile': False, 'settings': 0, 'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0,
|
||||||
|
'errors': []}
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file:
|
||||||
|
names = set(zip_file.namelist())
|
||||||
|
|
||||||
|
available_files = import_files(zip_file)
|
||||||
|
summary['files'] = len(available_files)
|
||||||
|
|
||||||
|
if 'profile.json' in names:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
summary['profile'] = import_profile(user, zip_file.read('profile.json'), available_files)
|
||||||
|
except Exception as error:
|
||||||
|
summary['errors'].append(f'Could not import profile.json: {error}')
|
||||||
|
|
||||||
|
if 'settings.json' in names:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
summary['settings'] = import_settings(user, zip_file.read('settings.json'))
|
||||||
|
except Exception as error:
|
||||||
|
summary['errors'].append(f'Could not import settings.json: {error}')
|
||||||
|
|
||||||
|
if 'locations.csv' in names:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
summary['locations'] = import_locations(user, zip_file.read('locations.csv'))
|
||||||
|
except Exception as error:
|
||||||
|
summary['errors'].append(f'Could not import locations.csv: {error}')
|
||||||
|
|
||||||
|
if 'friends.csv' in names:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
summary['friends'] = import_friends(user, zip_file.read('friends.csv'))
|
||||||
|
except Exception as error:
|
||||||
|
summary['errors'].append(f'Could not import friends.csv: {error}')
|
||||||
|
|
||||||
|
if 'inventory.csv' in names:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
summary['inventory_items'], inventory_errors = import_inventory(
|
||||||
|
user, zip_file.read('inventory.csv'), available_files)
|
||||||
|
summary['errors'].extend(inventory_errors)
|
||||||
|
except Exception as error:
|
||||||
|
summary['errors'].append(f'Could not import inventory.csv: {error}')
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_zip_bytes(zip_payload):
|
||||||
|
"""Normalize the incoming 'zip' request payload (uploaded file, base64 string, or raw bytes)."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
if hasattr(zip_payload, 'read'):
|
||||||
|
return zip_payload.read()
|
||||||
|
if isinstance(zip_payload, str):
|
||||||
|
try:
|
||||||
|
return base64.b64decode(zip_payload, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return zip_payload.encode('utf-8')
|
||||||
|
return zip_payload
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
def import_data(request, format=None):
|
||||||
|
local_user = local_user_or_none(request.user)
|
||||||
|
if local_user is None:
|
||||||
|
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
|
||||||
|
zip_payload = request.data.get('zip')
|
||||||
|
if not zip_payload:
|
||||||
|
return Response(status=400)
|
||||||
|
try:
|
||||||
|
zip_bytes = _extract_zip_bytes(zip_payload)
|
||||||
|
summary = import_user_data(local_user, zip_bytes)
|
||||||
|
except Exception as error:
|
||||||
|
return Response({'detail': f'Could not read zip file: {error}'}, status=400)
|
||||||
|
return Response(summary, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
def export_data(request, format=None):
|
||||||
|
local_user = local_user_or_none(request.user)
|
||||||
|
if local_user is None:
|
||||||
|
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
|
||||||
|
return HttpResponse(user_data(local_user), content_type='application/zip', status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
def delete_data(request, format=None):
|
||||||
|
"""Wipe all of the local user's data (everything included in the export), but keep the account.
|
||||||
|
|
||||||
|
This is *not* account deletion/closure - the user stays logged in and can keep using the
|
||||||
|
account (with all their data reset to a blank slate) afterwards.
|
||||||
|
"""
|
||||||
|
local_user = local_user_or_none(request.user)
|
||||||
|
if local_user is None:
|
||||||
|
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
|
||||||
|
summary = delete_user_data(local_user)
|
||||||
|
return Response(summary, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
def delete_account(request, format=None):
|
||||||
|
"""Permanently close the local user's account, after wiping all of its data.
|
||||||
|
|
||||||
|
Unlike `delete_data()`, this also removes the account itself - the user can no longer log
|
||||||
|
in afterwards. Their public identity is kept so remote friends/history referencing it stay
|
||||||
|
intact, but the local ToolshedUser row is gone.
|
||||||
|
"""
|
||||||
|
local_user = local_user_or_none(request.user)
|
||||||
|
if local_user is None:
|
||||||
|
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
|
||||||
|
summary = delete_user_account(local_user)
|
||||||
|
return Response(summary, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('export/', export_data, name='export_data'),
|
||||||
|
path('import/', import_data, name='import_data'),
|
||||||
|
path('account_data/', delete_data, name='delete_data'),
|
||||||
|
path('account/', delete_account, name='delete_account'),
|
||||||
|
]
|
||||||
28
backend/toolshed/migrations/0007_workflowinstance.py
Normal file
28
backend/toolshed/migrations/0007_workflowinstance.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 4.2.2 on 2025-09-26 10:20
|
||||||
|
|
||||||
|
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),
|
||||||
|
('toolshed', '0006_alter_tag_options_alter_category_name_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WorkflowInstance',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('state', models.CharField(max_length=255)),
|
||||||
|
('payload', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflows', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 4.2.2 on 2026-07-23 02:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('toolshed', '0007_workflowinstance'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inventoryitem',
|
||||||
|
name='storage_location',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventory_items', to='toolshed.storagelocation'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
from django_softdelete.models import SoftDeleteModel
|
from django_softdelete.models import SoftDeleteModel
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
@ -26,6 +26,10 @@ class Category(SoftDeleteModel):
|
||||||
parent = str(self.parent) + "/" if self.parent else ""
|
parent = str(self.parent) + "/" if self.parent else ""
|
||||||
return parent + self.name
|
return parent + self.name
|
||||||
|
|
||||||
|
def get_handle(self):
|
||||||
|
"""Return a fully qualified handle like 'git:base#category:tools'"""
|
||||||
|
return f"{self.origin}#category:{self.name}"
|
||||||
|
|
||||||
|
|
||||||
class Property(models.Model):
|
class Property(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|
@ -50,6 +54,10 @@ class Property(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_handle(self):
|
||||||
|
"""Return a fully qualified handle like 'git:base#property:length'"""
|
||||||
|
return f"{self.origin}#property:{self.name}"
|
||||||
|
|
||||||
|
|
||||||
class Tag(models.Model):
|
class Tag(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|
@ -69,6 +77,10 @@ class Tag(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_handle(self):
|
||||||
|
"""Return a fully qualified handle like 'git:tools#tag:drill'"""
|
||||||
|
return f"{self.origin}#tag:{self.name}"
|
||||||
|
|
||||||
|
|
||||||
class InventoryItem(SoftDeleteModel):
|
class InventoryItem(SoftDeleteModel):
|
||||||
AVAILABILITY_POLICY_CHOICES = (
|
AVAILABILITY_POLICY_CHOICES = (
|
||||||
|
|
@ -90,7 +102,7 @@ class InventoryItem(SoftDeleteModel):
|
||||||
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
|
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
|
||||||
properties = models.ManyToManyField(Property, through='ItemProperty')
|
properties = models.ManyToManyField(Property, through='ItemProperty')
|
||||||
files = models.ManyToManyField(File, related_name='connected_items')
|
files = models.ManyToManyField(File, related_name='connected_items')
|
||||||
storage_location = models.ForeignKey('StorageLocation', on_delete=models.CASCADE, null=True, blank=True,
|
storage_location = models.ForeignKey('StorageLocation', on_delete=models.SET_NULL, null=True, blank=True,
|
||||||
related_name='inventory_items')
|
related_name='inventory_items')
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
@ -120,3 +132,17 @@ class StorageLocation(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
parent = str(self.parent) + "/" if self.parent else ""
|
parent = str(self.parent) + "/" if self.parent else ""
|
||||||
return parent + self.name
|
return parent + self.name
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstance(models.Model):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
state = models.CharField(max_length=255)
|
||||||
|
payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend.
|
||||||
|
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.state})"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
670
backend/toolshed/offlinedata.py
Normal file
670
backend/toolshed/offlinedata.py
Normal file
|
|
@ -0,0 +1,670 @@
|
||||||
|
"""Data helpers for building/importing a user's offline export.
|
||||||
|
|
||||||
|
These functions deal with the toolshed models and CSV row shapes only - they know nothing
|
||||||
|
about the zip container format or how the request is authenticated. See toolshed/api/offlinedata.py
|
||||||
|
for the zip-building/parsing and the API views themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def inventory_rows(user):
|
||||||
|
"""Generator that yields the given user's inventory items as flattened dicts, one per row."""
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from toolshed.models import InventoryItem
|
||||||
|
|
||||||
|
items = (InventoryItem.objects
|
||||||
|
.filter(owner=user)
|
||||||
|
.select_related('category', 'storage_location')
|
||||||
|
.prefetch_related('tags', 'itemproperty_set__property', 'files'))
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
file_paths = []
|
||||||
|
for f in item.files.all():
|
||||||
|
extension = mimetypes.guess_extension(f.mime_type) or ''
|
||||||
|
file_paths.append(f'files/{f.hash}{extension}')
|
||||||
|
|
||||||
|
yield {
|
||||||
|
'id': item.id,
|
||||||
|
'name': item.name or '',
|
||||||
|
'description': item.description or '',
|
||||||
|
'category': item.category.get_handle() if item.category else '',
|
||||||
|
'availability_policy': item.availability_policy,
|
||||||
|
'owned_quantity': item.owned_quantity,
|
||||||
|
'storage_location': str(item.storage_location) if item.storage_location else '',
|
||||||
|
'tags': ', '.join(tag.get_handle() for tag in item.tags.all()),
|
||||||
|
'properties': _encode_properties_cell(item.itemproperty_set.all()),
|
||||||
|
'files': ', '.join(file_paths),
|
||||||
|
'created_at': item.created_at.isoformat() if item.created_at else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def friend_rows(user):
|
||||||
|
"""Generator that yields the given user's friends (known identities) as flattened dicts, one per row."""
|
||||||
|
friends = user.friends.all()
|
||||||
|
|
||||||
|
for friend in friends:
|
||||||
|
yield {
|
||||||
|
'username': friend.username,
|
||||||
|
'domain': friend.domain,
|
||||||
|
'handle': f'{friend.username}@{friend.domain}',
|
||||||
|
'public_key': friend.public_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def location_path(location):
|
||||||
|
"""Recursively build the '/'-joined path of a StorageLocation, following its parent chain."""
|
||||||
|
if location.parent:
|
||||||
|
return location_path(location.parent) + '/' + location.name
|
||||||
|
return location.name
|
||||||
|
|
||||||
|
|
||||||
|
def location_rows(user):
|
||||||
|
"""Generator that yields the given user's storage locations as flattened dicts, one per row."""
|
||||||
|
from toolshed.models import StorageLocation
|
||||||
|
|
||||||
|
locations = StorageLocation.objects.filter(owner=user).select_related('category', 'parent')
|
||||||
|
|
||||||
|
for location in locations:
|
||||||
|
yield {
|
||||||
|
'id': location.id,
|
||||||
|
'name': location.name,
|
||||||
|
'description': location.description or '',
|
||||||
|
'category': str(location.category) if location.category else '',
|
||||||
|
'parent': str(location.parent) if location.parent else '',
|
||||||
|
'path': location_path(location),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inventory_files(user):
|
||||||
|
"""Generator that yields (arcname, data) for each unique file attached to the user's inventory items.
|
||||||
|
|
||||||
|
Files are deduplicated by hash and placed under a 'files/' subfolder, keeping their original
|
||||||
|
extension (guessed from mime_type) so attachments and images remain viewable once extracted.
|
||||||
|
"""
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from toolshed.models import InventoryItem
|
||||||
|
|
||||||
|
seen_hashes = set()
|
||||||
|
items = InventoryItem.objects.filter(owner=user).prefetch_related('files')
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
for file in item.files.all():
|
||||||
|
if file.hash in seen_hashes:
|
||||||
|
continue
|
||||||
|
seen_hashes.add(file.hash)
|
||||||
|
|
||||||
|
extension = mimetypes.guess_extension(file.mime_type) or ''
|
||||||
|
arcname = f'files/{file.hash}{extension}'
|
||||||
|
|
||||||
|
file.file.open('rb')
|
||||||
|
try:
|
||||||
|
data = file.file.read()
|
||||||
|
finally:
|
||||||
|
file.file.close()
|
||||||
|
|
||||||
|
yield arcname, data
|
||||||
|
|
||||||
|
|
||||||
|
def profile_data(user):
|
||||||
|
"""Return the given user's profile as a dict, matching profile.json in the export zip."""
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'username': user.username,
|
||||||
|
'domain': user.domain,
|
||||||
|
'email': user.email,
|
||||||
|
'first_name': user.first_name or '',
|
||||||
|
'last_name': user.last_name or '',
|
||||||
|
'profile_picture': None,
|
||||||
|
}
|
||||||
|
if user.profile_picture:
|
||||||
|
extension = mimetypes.guess_extension(user.profile_picture.mime_type) or ''
|
||||||
|
data['profile_picture'] = f'files/{user.profile_picture.hash}{extension}'
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def profile_picture_files(user):
|
||||||
|
"""Generator that yields (arcname, data) for the user's profile picture, if one is set.
|
||||||
|
|
||||||
|
Kept separate from `inventory_files()` so the profile picture is included in the export
|
||||||
|
even for users with no inventory items or whose picture isn't attached to any item.
|
||||||
|
"""
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
if not user.profile_picture:
|
||||||
|
return
|
||||||
|
|
||||||
|
extension = mimetypes.guess_extension(user.profile_picture.mime_type) or ''
|
||||||
|
arcname = f'files/{user.profile_picture.hash}{extension}'
|
||||||
|
|
||||||
|
user.profile_picture.file.open('rb')
|
||||||
|
try:
|
||||||
|
data = user.profile_picture.file.read()
|
||||||
|
finally:
|
||||||
|
user.profile_picture.file.close()
|
||||||
|
|
||||||
|
yield arcname, data
|
||||||
|
|
||||||
|
|
||||||
|
def settings_data(user):
|
||||||
|
"""Return the given user's account-level preferences as a {key: value} dict for settings.json.
|
||||||
|
|
||||||
|
Only account preferences (AccountPreference) are exported; device-level preferences stay
|
||||||
|
in the browser's local storage since they describe the device, not the account.
|
||||||
|
"""
|
||||||
|
return {pref.key: pref.value for pref in user.preferences.all()}
|
||||||
|
|
||||||
|
|
||||||
|
def import_profile(user, data, available_files):
|
||||||
|
"""Fault-tolerant import of profile.json, updating the user's editable profile fields.
|
||||||
|
|
||||||
|
Only 'first_name', 'last_name', 'email' and 'profile_picture' (a 'files/...' path present in
|
||||||
|
`available_files`) are applied; 'username' and 'domain' are ignored since they identify the
|
||||||
|
account itself and can't be changed by an import.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = json.loads(data.decode('utf-8'))
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'first_name' in profile:
|
||||||
|
user.first_name = profile.get('first_name') or ''
|
||||||
|
if 'last_name' in profile:
|
||||||
|
user.last_name = profile.get('last_name') or ''
|
||||||
|
if profile.get('email'):
|
||||||
|
user.email = profile['email']
|
||||||
|
|
||||||
|
picture_path = profile.get('profile_picture')
|
||||||
|
if picture_path and picture_path in available_files:
|
||||||
|
user.profile_picture = available_files[picture_path]
|
||||||
|
|
||||||
|
user.save()
|
||||||
|
return True
|
||||||
|
except Exception as error:
|
||||||
|
print(f'Skipping profile.json: {error}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def import_settings(user, data):
|
||||||
|
"""Fault-tolerant import of settings.json into the user's account-level preferences.
|
||||||
|
|
||||||
|
Each top-level key/value pair is upserted as an AccountPreference; unreadable data or a
|
||||||
|
non-object payload is skipped rather than aborting the whole import.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from authentication.models import AccountPreference
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = json.loads(data.decode('utf-8'))
|
||||||
|
if not isinstance(settings, dict):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
for key, value in settings.items():
|
||||||
|
try:
|
||||||
|
AccountPreference.objects.update_or_create(user=user, key=key, defaults={'value': value})
|
||||||
|
imported += 1
|
||||||
|
except Exception as error:
|
||||||
|
print(f'Skipping setting "{key}": {error}')
|
||||||
|
return imported
|
||||||
|
except Exception as error:
|
||||||
|
print(f'Skipping settings.json: {error}')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_data(user):
|
||||||
|
"""Permanently delete everything that `user_data()` exports, keeping the account itself intact.
|
||||||
|
|
||||||
|
This removes the user's inventory items (hard delete, bypassing soft-delete), storage
|
||||||
|
locations, account preferences, the friends relation on their public identity, the profile
|
||||||
|
picture, and any File blobs (profile picture / inventory attachments) that would otherwise be
|
||||||
|
orphaned - i.e. not referenced by any other inventory item or user. Files still referenced
|
||||||
|
elsewhere (they're deduplicated by content hash) are left untouched. The ToolshedUser account
|
||||||
|
(and its underlying KnownIdentity) is *not* deleted - this only wipes the account's data, it
|
||||||
|
doesn't close the account. See `delete_account()` in toolshed/api/offlinedata.py for that.
|
||||||
|
|
||||||
|
Returns a summary dict describing what was removed.
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from toolshed.models import InventoryItem, StorageLocation
|
||||||
|
|
||||||
|
summary = {'inventory_items': 0, 'locations': 0, 'settings': 0, 'friends': 0, 'files': 0}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
candidate_file_ids = set(
|
||||||
|
InventoryItem.global_objects.filter(owner=user).values_list('files__id', flat=True))
|
||||||
|
candidate_file_ids.discard(None)
|
||||||
|
if user.profile_picture_id:
|
||||||
|
candidate_file_ids.add(user.profile_picture_id)
|
||||||
|
|
||||||
|
for item in InventoryItem.global_objects.filter(owner=user):
|
||||||
|
item.hard_delete()
|
||||||
|
summary['inventory_items'] += 1
|
||||||
|
|
||||||
|
summary['locations'], _ = StorageLocation.objects.filter(owner=user).delete()
|
||||||
|
summary['settings'], _ = user.preferences.all().delete()
|
||||||
|
|
||||||
|
summary['friends'] = user.public_identity.friends.count()
|
||||||
|
user.public_identity.friends.clear()
|
||||||
|
|
||||||
|
user.profile_picture = None
|
||||||
|
user.save(update_fields=['profile_picture'])
|
||||||
|
|
||||||
|
summary['files'] = _delete_orphaned_files(candidate_file_ids)
|
||||||
|
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_account(user):
|
||||||
|
"""Permanently delete the local user's account, after wiping all of its data.
|
||||||
|
|
||||||
|
This first calls `delete_user_data()` to remove everything covered by the data export
|
||||||
|
(inventory, locations, settings, friends relation, profile picture and orphaned files),
|
||||||
|
then deletes the ToolshedUser row itself. The underlying KnownIdentity is kept so remote
|
||||||
|
friends/history relating to this identity remain intact - only the local account is closed.
|
||||||
|
|
||||||
|
Returns a summary dict describing what was removed, with `account` set to True.
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
summary = delete_user_data(user)
|
||||||
|
user.delete()
|
||||||
|
summary['account'] = True
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_orphaned_files(file_ids):
|
||||||
|
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced.
|
||||||
|
|
||||||
|
A File is considered orphaned once no InventoryItem and no ToolshedUser (profile picture)
|
||||||
|
references it anymore. Returns the number of files deleted.
|
||||||
|
"""
|
||||||
|
from files.models import File
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for file_obj in File.objects.filter(id__in=file_ids):
|
||||||
|
if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists():
|
||||||
|
continue
|
||||||
|
file_obj.file.delete(save=False)
|
||||||
|
file_obj.delete()
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def rows_to_csv(rows, fieldnames=None, encoding='utf-8'):
|
||||||
|
"""Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk."""
|
||||||
|
import csv
|
||||||
|
|
||||||
|
class _Echo:
|
||||||
|
"""A file-like object whose write() just returns what was passed, for streaming csv.writer output."""
|
||||||
|
|
||||||
|
def write(self, value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
rows = iter(rows)
|
||||||
|
try:
|
||||||
|
first_row = next(rows)
|
||||||
|
except StopIteration:
|
||||||
|
return
|
||||||
|
|
||||||
|
if fieldnames is None:
|
||||||
|
fieldnames = list(first_row.keys())
|
||||||
|
|
||||||
|
writer = csv.DictWriter(_Echo(), fieldnames=fieldnames)
|
||||||
|
yield writer.writeheader().encode(encoding)
|
||||||
|
yield writer.writerow(first_row).encode(encoding)
|
||||||
|
for row in rows:
|
||||||
|
yield writer.writerow(row).encode(encoding)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_csv_rows(data, encoding='utf-8'):
|
||||||
|
"""Decode CSV bytes and yield rows as dicts keyed by the header labels (not column offsets)."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(data.decode(encoding)))
|
||||||
|
for row in reader:
|
||||||
|
yield row
|
||||||
|
|
||||||
|
|
||||||
|
def _field_is_optional(model, field_name):
|
||||||
|
"""Return True if a field may be omitted: it's a relation, has a default, or allows null/blank."""
|
||||||
|
try:
|
||||||
|
field = model._meta.get_field(field_name)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
if getattr(field, 'many_to_many', False) or getattr(field, 'one_to_many', False):
|
||||||
|
return True
|
||||||
|
return bool(getattr(field, 'null', False) or getattr(field, 'blank', False) or field.has_default())
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_category(path):
|
||||||
|
"""Resolve or create a Category from a '/'-separated path such as 'Electronics/Cables'."""
|
||||||
|
from toolshed.models import Category
|
||||||
|
|
||||||
|
parent = None
|
||||||
|
category = None
|
||||||
|
for part in (p for p in path.split('/') if p):
|
||||||
|
category, _ = Category.objects.get_or_create(name=part, parent=parent, defaults={'origin': 'import'})
|
||||||
|
parent = category
|
||||||
|
return category
|
||||||
|
|
||||||
|
|
||||||
|
def import_locations(user, data):
|
||||||
|
"""Fault-tolerant import of locations.csv into StorageLocations owned by `user`.
|
||||||
|
|
||||||
|
Rows are read by header label. Rows missing the required 'name' column are skipped.
|
||||||
|
Optional columns ('description', 'category') are simply omitted if absent. Locations are
|
||||||
|
processed in path-depth order so a child's parent already exists by the time it's needed.
|
||||||
|
Each row runs in its own transaction savepoint so a DB-level failure on one row (e.g. a
|
||||||
|
constraint violation) can't poison the surrounding transaction and silently break every
|
||||||
|
subsequent row.
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from toolshed.models import StorageLocation
|
||||||
|
|
||||||
|
rows = list(_read_csv_rows(data))
|
||||||
|
rows.sort(key=lambda row: (row.get('path') or row.get('name') or '').count('/'))
|
||||||
|
|
||||||
|
resolved_by_path = {}
|
||||||
|
imported = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
name = (row.get('name') or '').strip()
|
||||||
|
if not name and not _field_is_optional(StorageLocation, 'name'):
|
||||||
|
continue # required column missing, skip this row
|
||||||
|
|
||||||
|
path = (row.get('path') or name).strip()
|
||||||
|
parent = None
|
||||||
|
if '/' in path:
|
||||||
|
parent = resolved_by_path.get(path.rsplit('/', 1)[0])
|
||||||
|
|
||||||
|
category = None
|
||||||
|
category_path = row.get('category')
|
||||||
|
if category_path:
|
||||||
|
category = get_or_create_category(category_path)
|
||||||
|
|
||||||
|
location, _ = StorageLocation.objects.update_or_create(
|
||||||
|
owner=user, name=name, parent=parent,
|
||||||
|
defaults={
|
||||||
|
'description': row.get('description', '') or '',
|
||||||
|
'category': category,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resolved_by_path[path] = location
|
||||||
|
imported += 1
|
||||||
|
except Exception as error:
|
||||||
|
print(f'Skipping location row {row}: {error}')
|
||||||
|
|
||||||
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
def import_friends(user, data):
|
||||||
|
"""Fault-tolerant import of friends.csv, adding valid entries to the user's known friends."""
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from authentication.models import KnownIdentity
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
for row in _read_csv_rows(data):
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
username = row.get('username')
|
||||||
|
domain = row.get('domain')
|
||||||
|
handle = row.get('handle')
|
||||||
|
if (not username or not domain) and handle and '@' in handle:
|
||||||
|
username, domain = handle.split('@', 1)
|
||||||
|
|
||||||
|
public_key = row.get('public_key')
|
||||||
|
if not username or not domain or not public_key:
|
||||||
|
continue # required fields missing, skip this row
|
||||||
|
|
||||||
|
identity, _ = KnownIdentity.objects.get_or_create(
|
||||||
|
username=username, domain=domain,
|
||||||
|
defaults={'public_key': public_key},
|
||||||
|
)
|
||||||
|
user.public_identity.friends.add(identity)
|
||||||
|
imported += 1
|
||||||
|
except Exception as error:
|
||||||
|
print(f'Skipping friend row {row}: {error}')
|
||||||
|
|
||||||
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
class _HandleNotFound(Exception):
|
||||||
|
"""Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved.
|
||||||
|
|
||||||
|
This intentionally aborts the whole row (rather than falling back to creating a new
|
||||||
|
entity), since a handle references a *specific* entity from a *specific* origin - silently
|
||||||
|
creating a new local entity named after the raw handle string would be incorrect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_handle(value, entity_type, model):
|
||||||
|
"""Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance.
|
||||||
|
|
||||||
|
Never creates anything: a handle references a specific entity from a specific origin, so
|
||||||
|
silently creating a new local entity named after the raw handle string would be incorrect.
|
||||||
|
Raises `_HandleNotFound` if the handle's entity type doesn't match or no such object exists,
|
||||||
|
so the caller can skip the row and report a helpful error instead.
|
||||||
|
"""
|
||||||
|
origin, rest = value.split('#', 1)
|
||||||
|
if ':' in rest:
|
||||||
|
found_type, name = rest.split(':', 1)
|
||||||
|
if found_type != entity_type:
|
||||||
|
raise _HandleNotFound(
|
||||||
|
f"expected a {entity_type} handle but got '{value}' (type '{found_type}')")
|
||||||
|
else:
|
||||||
|
name = rest
|
||||||
|
try:
|
||||||
|
return model.objects.get(origin=origin, name=name)
|
||||||
|
except model.DoesNotExist:
|
||||||
|
raise _HandleNotFound(f"{entity_type} '{value}' does not exist, skipping item")
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_value_if_needed(value):
|
||||||
|
"""Wrap `value` in double quotes (CSV-style, doubling any embedded quotes) if it contains a
|
||||||
|
comma or a quote character, so it survives sitting inside a comma-separated "handle=value"
|
||||||
|
list unambiguously.
|
||||||
|
"""
|
||||||
|
if any(ch in value for ch in ',"'):
|
||||||
|
return '"' + value.replace('"', '""') + '"'
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_properties_cell(item_properties):
|
||||||
|
"""Encode an item's properties as a comma-separated "handle=value" list for the 'properties'
|
||||||
|
CSV cell, quoting a value (CSV-style) when it contains a comma or a quote character so it
|
||||||
|
still round-trips correctly. See `_parse_properties_cell()` for the reader side.
|
||||||
|
"""
|
||||||
|
entries = [
|
||||||
|
f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}"
|
||||||
|
for ip in item_properties
|
||||||
|
]
|
||||||
|
return ', '.join(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_quoted_comma_list(raw_value):
|
||||||
|
"""Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style,
|
||||||
|
wherever they appear in an entry) so a quoted value's own commas aren't mistaken for
|
||||||
|
separators. Doubled quotes ("") inside a quoted substring are unescaped to a single literal
|
||||||
|
quote, and the surrounding quotes themselves are stripped from the result.
|
||||||
|
|
||||||
|
Only the single space after each ", " separator (as written by `_encode_properties_cell()`)
|
||||||
|
is dropped - unlike a blanket `.strip()`, this preserves any leading/trailing whitespace that
|
||||||
|
is genuinely part of a (quoted) value.
|
||||||
|
"""
|
||||||
|
entries = []
|
||||||
|
current = []
|
||||||
|
in_quotes = False
|
||||||
|
i = 0
|
||||||
|
length = len(raw_value)
|
||||||
|
|
||||||
|
while i < length:
|
||||||
|
char = raw_value[i]
|
||||||
|
if char == '"':
|
||||||
|
if in_quotes and i + 1 < length and raw_value[i + 1] == '"':
|
||||||
|
current.append('"')
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
in_quotes = not in_quotes
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if char == ',' and not in_quotes:
|
||||||
|
entries.append(''.join(current))
|
||||||
|
current = []
|
||||||
|
i += 1
|
||||||
|
if i < length and raw_value[i] == ' ':
|
||||||
|
i += 1 # skip the single space of the ", " separator
|
||||||
|
continue
|
||||||
|
current.append(char)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
entries.append(''.join(current))
|
||||||
|
return [entry for entry in entries if entry]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_properties_cell(raw_value, resolve_property):
|
||||||
|
"""Parse the 'properties' CSV cell into a list of (Property, value) tuples.
|
||||||
|
|
||||||
|
The cell is a comma-separated list of "handle=value" entries; a value containing a comma or
|
||||||
|
a quote character is double-quoted (CSV-style) by `_encode_properties_cell()` so it survives
|
||||||
|
the round trip intact.
|
||||||
|
"""
|
||||||
|
raw_value = (raw_value or '').strip()
|
||||||
|
if not raw_value:
|
||||||
|
return []
|
||||||
|
|
||||||
|
properties = []
|
||||||
|
for prop_entry in _split_quoted_comma_list(raw_value):
|
||||||
|
if '=' not in prop_entry:
|
||||||
|
continue
|
||||||
|
prop_name, value = prop_entry.split('=', 1)
|
||||||
|
prop = resolve_property(prop_name.strip())
|
||||||
|
if prop:
|
||||||
|
properties.append((prop, value))
|
||||||
|
|
||||||
|
return properties
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def import_inventory(user, data, available_files):
|
||||||
|
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`.
|
||||||
|
|
||||||
|
`available_files` maps the 'files' column's paths to File instances successfully extracted
|
||||||
|
from the zip; references to missing files are ignored rather than failing the row.
|
||||||
|
|
||||||
|
If a row references a fully qualified tag/property/category handle that doesn't exist
|
||||||
|
locally, the whole item is skipped (rather than creating a bogus local entity named after
|
||||||
|
the raw handle) and a helpful message is added to the returned `errors` list. Each row runs
|
||||||
|
in its own transaction savepoint so a DB-level failure on one row can't poison the
|
||||||
|
surrounding transaction and silently break every subsequent row.
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property
|
||||||
|
|
||||||
|
def resolve_category_from_csv(value):
|
||||||
|
"""Resolve a category from CSV - supports both old format and fully qualified handles"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if '#' in value:
|
||||||
|
return _resolve_handle(value, 'category', Category)
|
||||||
|
# Fallback to old path-based lookup
|
||||||
|
return get_or_create_category(value)
|
||||||
|
|
||||||
|
def resolve_tag_from_csv(value):
|
||||||
|
"""Resolve a tag from CSV - supports both old format and fully qualified handles"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if '#' in value:
|
||||||
|
return _resolve_handle(value, 'tag', Tag)
|
||||||
|
# Fallback to old name-only lookup or create
|
||||||
|
return Tag.objects.get_or_create(name=value, category=None, defaults={'origin': 'import'})[0]
|
||||||
|
|
||||||
|
def resolve_property_from_csv(value):
|
||||||
|
"""Resolve a property from CSV - supports both old format and fully qualified handles"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if '#' in value:
|
||||||
|
return _resolve_handle(value, 'property', Property)
|
||||||
|
# Fallback to old name-only lookup or create
|
||||||
|
return Property.objects.get_or_create(name=value, category=None, defaults={'origin': 'import'})[0]
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
errors = []
|
||||||
|
for row in _read_csv_rows(data):
|
||||||
|
name = (row.get('name') or '').strip()
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
file_paths = [p.strip() for p in (row.get('files') or '').split(',') if p.strip()]
|
||||||
|
files = [available_files[p] for p in file_paths if p in available_files]
|
||||||
|
|
||||||
|
if not name and not files:
|
||||||
|
continue # InventoryItem requires a name or at least one file
|
||||||
|
|
||||||
|
category = resolve_category_from_csv((row.get('category') or '').strip())
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
for tag_name in (row.get('tags') or '').split(','):
|
||||||
|
tag_name = tag_name.strip()
|
||||||
|
if tag_name:
|
||||||
|
tag = resolve_tag_from_csv(tag_name)
|
||||||
|
if tag:
|
||||||
|
tags.append(tag)
|
||||||
|
|
||||||
|
properties = _parse_properties_cell(row.get('properties') or '', resolve_property_from_csv)
|
||||||
|
|
||||||
|
storage_location = None
|
||||||
|
location_path_value = row.get('storage_location')
|
||||||
|
if location_path_value:
|
||||||
|
storage_location = StorageLocation.objects.filter(
|
||||||
|
owner=user, name=location_path_value.rsplit('/', 1)[-1]).first()
|
||||||
|
|
||||||
|
try:
|
||||||
|
owned_quantity = int(row.get('owned_quantity') or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
owned_quantity = 1
|
||||||
|
|
||||||
|
item = InventoryItem.objects.create(
|
||||||
|
owner=user,
|
||||||
|
name=name or None,
|
||||||
|
description=row.get('description', '') or '',
|
||||||
|
category=category,
|
||||||
|
availability_policy=row.get('availability_policy') or 'private',
|
||||||
|
owned_quantity=owned_quantity,
|
||||||
|
storage_location=storage_location,
|
||||||
|
)
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
item.tags.add(tag, through_defaults={})
|
||||||
|
|
||||||
|
for prop, value in properties:
|
||||||
|
ItemProperty.objects.create(inventory_item=item, property=prop, value=value)
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
item.files.add(file)
|
||||||
|
|
||||||
|
imported += 1
|
||||||
|
except _HandleNotFound as error:
|
||||||
|
message = f"Skipping item '{name or 'unnamed'}': {error}"
|
||||||
|
print(message)
|
||||||
|
errors.append(message)
|
||||||
|
except Exception as error:
|
||||||
|
message = f'Skipping inventory row {row}: {error}'
|
||||||
|
print(message)
|
||||||
|
errors.append(message)
|
||||||
|
|
||||||
|
return imported, errors
|
||||||
|
|
||||||
|
|
@ -3,7 +3,47 @@ from authentication.models import KnownIdentity, ToolshedUser, FriendRequestInco
|
||||||
from authentication.serializers import OwnerSerializer
|
from authentication.serializers import OwnerSerializer
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from files.serializers import FileSerializer
|
from files.serializers import FileSerializer
|
||||||
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation
|
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance
|
||||||
|
|
||||||
|
|
||||||
|
def parse_handle(handle):
|
||||||
|
"""Parse a fully qualified handle like 'git:base#property:length' into (origin, entity_type, name)"""
|
||||||
|
if '#' not in handle:
|
||||||
|
# Fallback to old format (just name)
|
||||||
|
return None, None, handle
|
||||||
|
origin, rest = handle.split('#', 1)
|
||||||
|
if ':' not in rest:
|
||||||
|
return origin, None, rest
|
||||||
|
entity_type, name = rest.split(':', 1)
|
||||||
|
return origin, entity_type, name
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_category_handle(handle):
|
||||||
|
"""Resolve a fully qualified handle to a Category object"""
|
||||||
|
origin, entity_type, name = parse_handle(handle)
|
||||||
|
if origin and entity_type == 'category':
|
||||||
|
return Category.objects.get(origin=origin, name=name)
|
||||||
|
# Fallback to name-only lookup
|
||||||
|
return Category.objects.get(name=handle.split('/')[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_property_handle(handle):
|
||||||
|
"""Resolve a fully qualified handle to a Property object"""
|
||||||
|
origin, entity_type, name = parse_handle(handle)
|
||||||
|
if origin and entity_type == 'property':
|
||||||
|
return Property.objects.get(origin=origin, name=name)
|
||||||
|
# Fallback to name-only lookup
|
||||||
|
return Property.objects.get(name=handle)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tag_handle(handle):
|
||||||
|
"""Resolve a fully qualified handle to a Tag object"""
|
||||||
|
origin, entity_type, name = parse_handle(handle)
|
||||||
|
if origin and entity_type == 'tag':
|
||||||
|
return Tag.objects.get(origin=origin, name=name)
|
||||||
|
# Fallback to name-only lookup
|
||||||
|
return Tag.objects.get(name=handle)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class FriendSerializer(serializers.ModelSerializer):
|
class FriendSerializer(serializers.ModelSerializer):
|
||||||
|
|
@ -29,33 +69,45 @@ class FriendRequestSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class PropertySerializer(serializers.ModelSerializer):
|
class PropertySerializer(serializers.ModelSerializer):
|
||||||
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
|
category = serializers.SerializerMethodField()
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_category(self, obj):
|
||||||
|
return resolve_category_handle(obj.category.get_handle()) if obj.category else None
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return obj.get_handle()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Property
|
model = Property
|
||||||
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix']
|
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix', 'handle']
|
||||||
|
|
||||||
|
|
||||||
class CategorySerializer(serializers.ModelSerializer):
|
class CategorySerializer(serializers.ModelSerializer):
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
fields = ['name']
|
fields = ['name', 'handle']
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return obj.get_handle()
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
return str(instance)
|
return instance.name
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
return Category.objects.get(name=data.split("/")[-1])
|
return resolve_category_handle(data.split("/")[-1])
|
||||||
|
|
||||||
|
|
||||||
class StorageLocationSerializer(serializers.ModelSerializer):
|
class StorageLocationSerializer(serializers.ModelSerializer):
|
||||||
owner = OwnerSerializer(read_only=True)
|
owner = OwnerSerializer(read_only=True)
|
||||||
category = CategorySerializer(required=False, allow_null=True)
|
category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
path = serializers.SerializerMethodField()
|
path = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StorageLocation
|
model = StorageLocation
|
||||||
fields = ['id', 'name', 'description', 'path', 'category', 'owner']
|
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
|
||||||
read_only_fields = ['path']
|
read_only_fields = ['path']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -67,23 +119,28 @@ class StorageLocationSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class ItemPropertySerializer(serializers.ModelSerializer):
|
class ItemPropertySerializer(serializers.ModelSerializer):
|
||||||
property = PropertySerializer(read_only=True)
|
property = PropertySerializer(read_only=True)
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ItemProperty
|
model = ItemProperty
|
||||||
fields = ['property', 'value']
|
fields = ['property', 'value', 'handle']
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return obj.property.get_handle()
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
return {'value': instance.value, 'name': instance.property.name}
|
return {'value': instance.value, 'name': instance.property.name}
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
prop = Property.objects.get(name=data['name'])
|
prop = resolve_property_handle(data.get('name') or data.get('handle'))
|
||||||
value = data['value']
|
value = data['value']
|
||||||
return {'property': prop, 'value': value}
|
return {'property': prop, 'value': value}
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemSerializer(serializers.ModelSerializer):
|
class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
owner = OwnerSerializer(read_only=True)
|
owner = OwnerSerializer(read_only=True)
|
||||||
tags = serializers.SlugRelatedField(many=True, required=False, queryset=Tag.objects.all(), slug_field='name')
|
tags = serializers.SerializerMethodField()
|
||||||
|
tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
|
||||||
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
||||||
category = CategorySerializer(required=False, allow_null=True)
|
category = CategorySerializer(required=False, allow_null=True)
|
||||||
files = FileSerializer(many=True, read_only=True)
|
files = FileSerializer(many=True, read_only=True)
|
||||||
|
|
@ -91,11 +148,16 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
|
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
|
||||||
'tags', 'properties', 'files', 'storage_location']
|
'tags', 'tags_input', 'properties', 'files', 'storage_location']
|
||||||
|
|
||||||
|
def get_tags(self, obj):
|
||||||
|
return [tag.name for tag in obj.tags.all()]
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
files = data.pop('files', [])
|
files = data.pop('files', [])
|
||||||
|
tags_input = data.pop('tags_input', data.pop('tags', []))
|
||||||
ret = super().to_internal_value(data)
|
ret = super().to_internal_value(data)
|
||||||
|
ret['tags'] = [resolve_tag_handle(tag) for tag in tags_input]
|
||||||
ret['files'] = files
|
ret['files'] = files
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
@ -138,3 +200,12 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value'])
|
ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value'])
|
||||||
item.save()
|
item.save()
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
class WorkflowInstanceSerializer(serializers.ModelSerializer):
|
||||||
|
owner = serializers.StringRelatedField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = WorkflowInstance
|
||||||
|
fields = ['id', 'name', 'state', 'payload', 'owner', 'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['owner', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation
|
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
class CategoryTestMixin:
|
class CategoryTestMixin:
|
||||||
|
|
@ -54,3 +55,33 @@ class LocationTestMixin:
|
||||||
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
|
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
|
||||||
self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
|
self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
|
||||||
category=self.f['cat1'])
|
category=self.f['cat1'])
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowTestMixin:
|
||||||
|
def prepare_workflows(self):
|
||||||
|
# `payload` is an opaque, frontend-serialized JSON string on the backend.
|
||||||
|
self.f['workflow1'] = WorkflowInstance.objects.create(
|
||||||
|
name='workflow1',
|
||||||
|
state='initial',
|
||||||
|
payload=json.dumps({}),
|
||||||
|
owner=self.f['local_user1']
|
||||||
|
)
|
||||||
|
self.f['workflow2'] = WorkflowInstance.objects.create(
|
||||||
|
name='workflow1',
|
||||||
|
state='upload',
|
||||||
|
payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}),
|
||||||
|
owner=self.f['local_user1']
|
||||||
|
)
|
||||||
|
self.f['workflow3'] = WorkflowInstance.objects.create(
|
||||||
|
name='workflow1',
|
||||||
|
state='describe',
|
||||||
|
payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'b1c2d3e4f5a60718293b4c5d6e7f8090'],
|
||||||
|
'descriptions': ['file 1 description']}),
|
||||||
|
owner=self.f['local_user1']
|
||||||
|
)
|
||||||
|
self.f['workflow_user2'] = WorkflowInstance.objects.create(
|
||||||
|
name='workflow2',
|
||||||
|
state='initial',
|
||||||
|
payload=json.dumps({}),
|
||||||
|
owner=self.f['local_user2']
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
|
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
|
||||||
from files.tests import FilesTestMixin
|
from files.tests import FilesTestMixin
|
||||||
from toolshed.models import InventoryItem, Category
|
from toolshed.models import InventoryItem, Category, StorageLocation
|
||||||
from toolshed.tests import InventoryTestMixin, LocationTestMixin
|
from toolshed.tests import InventoryTestMixin, LocationTestMixin
|
||||||
|
|
||||||
client = SignatureAuthClient()
|
client = SignatureAuthClient()
|
||||||
|
|
@ -69,3 +69,63 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
||||||
self.assertEqual(reply.json()[3]['description'], None)
|
self.assertEqual(reply.json()[3]['description'], None)
|
||||||
self.assertEqual(reply.json()[3]['category'], 'cat1')
|
self.assertEqual(reply.json()[3]['category'], 'cat1')
|
||||||
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4')
|
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4')
|
||||||
|
|
||||||
|
def test_post_new_location(self):
|
||||||
|
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
|
||||||
|
'name': 'loc5',
|
||||||
|
'description': 'a new location',
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 201)
|
||||||
|
self.assertEqual(StorageLocation.objects.count(), 5)
|
||||||
|
location = StorageLocation.objects.get(name='loc5')
|
||||||
|
self.assertEqual(location.description, 'a new location')
|
||||||
|
self.assertEqual(location.owner, self.f['local_user1'])
|
||||||
|
self.assertEqual(location.parent, None)
|
||||||
|
self.assertEqual(reply.json()['path'], 'loc5')
|
||||||
|
|
||||||
|
def test_post_new_nested_location(self):
|
||||||
|
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
|
||||||
|
'name': 'loc5',
|
||||||
|
'parent': self.f['loc3'].id,
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 201)
|
||||||
|
location = StorageLocation.objects.get(name='loc5')
|
||||||
|
self.assertEqual(location.parent, self.f['loc3'])
|
||||||
|
self.assertEqual(reply.json()['path'], 'loc1/loc3/loc5')
|
||||||
|
|
||||||
|
def test_patch_location(self):
|
||||||
|
reply = client.patch('/api/storage_locations/' + str(self.f['loc2'].id) + '/', self.f['local_user1'], {
|
||||||
|
'name': 'loc2-renamed',
|
||||||
|
'parent': self.f['loc1'].id,
|
||||||
|
})
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
location = StorageLocation.objects.get(id=self.f['loc2'].id)
|
||||||
|
self.assertEqual(location.name, 'loc2-renamed')
|
||||||
|
self.assertEqual(location.parent, self.f['loc1'])
|
||||||
|
self.assertEqual(reply.json()['path'], 'loc1/loc2-renamed')
|
||||||
|
|
||||||
|
def test_delete_location(self):
|
||||||
|
reply = client.delete('/api/storage_locations/' + str(self.f['loc4'].id) + '/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 204)
|
||||||
|
self.assertEqual(StorageLocation.objects.count(), 3)
|
||||||
|
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc4'].id).count(), 0)
|
||||||
|
|
||||||
|
def test_delete_location_with_items_sets_null(self):
|
||||||
|
item = InventoryItem.objects.create(
|
||||||
|
owner=self.f['local_user1'], name='located_item', storage_location=self.f['loc3'])
|
||||||
|
reply = client.delete('/api/storage_locations/' + str(self.f['loc3'].id) + '/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 204)
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertIsNone(item.storage_location)
|
||||||
|
self.assertEqual(InventoryItem.objects.filter(id=item.id).count(), 1)
|
||||||
|
|
||||||
|
def test_locations_are_owner_scoped(self):
|
||||||
|
reply = client.get('/api/storage_locations/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(len(reply.json()), 0)
|
||||||
|
|
||||||
|
def test_cannot_delete_other_users_location(self):
|
||||||
|
reply = client.delete('/api/storage_locations/' + str(self.f['loc1'].id) + '/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1)
|
||||||
|
|
||||||
|
|
|
||||||
264
backend/toolshed/tests/test_offlinedata.py
Normal file
264
backend/toolshed/tests/test_offlinedata.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
from authentication.models import AccountPreference, ToolshedUser
|
||||||
|
from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase
|
||||||
|
from files.models import File
|
||||||
|
from toolshed.models import InventoryItem, ItemProperty, StorageLocation
|
||||||
|
from toolshed.offlinedata import import_inventory, inventory_rows, rows_to_csv
|
||||||
|
from toolshed.tests import CategoryTestMixin, LocationTestMixin, PropertyTestMixin, TagTestMixin
|
||||||
|
|
||||||
|
anonymous_client = Client()
|
||||||
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
||||||
|
class _DeleteTestDataMixin(UserTestMixin, CategoryTestMixin, LocationTestMixin):
|
||||||
|
"""Shared fixture setup for the delete-data and delete-account test cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_categories()
|
||||||
|
self.prepare_locations()
|
||||||
|
|
||||||
|
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
|
||||||
|
|
||||||
|
self.f['shared_file'] = File.objects.create(
|
||||||
|
file=ContentFile(b'shared', 'shared'), mime_type='text/plain', hash='shared')
|
||||||
|
self.f['orphan_file'] = File.objects.create(
|
||||||
|
file=ContentFile(b'orphan', 'orphan'), mime_type='text/plain', hash='orphan')
|
||||||
|
|
||||||
|
self.f['item1'] = InventoryItem.objects.create(
|
||||||
|
owner=self.f['local_user1'], owned_quantity=1, name='item1', category=self.f['cat1'])
|
||||||
|
self.f['item1'].files.add(self.f['orphan_file'])
|
||||||
|
|
||||||
|
self.f['item_other_user'] = InventoryItem.objects.create(
|
||||||
|
owner=self.f['local_user2'], owned_quantity=1, name='item2', category=self.f['cat1'])
|
||||||
|
self.f['item_other_user'].files.add(self.f['shared_file'])
|
||||||
|
|
||||||
|
self.f['item1'].files.add(self.f['shared_file'])
|
||||||
|
|
||||||
|
AccountPreference.objects.create(user=self.f['local_user1'], key='theme', value='dark')
|
||||||
|
|
||||||
|
self.f['local_user1'].profile_picture = self.f['orphan_file']
|
||||||
|
self.f['local_user1'].save()
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteDataTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
def test_delete_data_anonymous(self):
|
||||||
|
response = anonymous_client.delete('/api/account_data/')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_delete_data_removes_all_owned_data_but_keeps_account(self):
|
||||||
|
response = client.delete('/api/account_data/', self.f['local_user1'])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
summary = response.json()
|
||||||
|
self.assertEqual(summary['inventory_items'], 1)
|
||||||
|
self.assertEqual(summary['locations'], 4)
|
||||||
|
self.assertEqual(summary['settings'], 1)
|
||||||
|
self.assertEqual(summary['friends'], 1)
|
||||||
|
|
||||||
|
# the account itself survives - this wipes data, it doesn't close the account
|
||||||
|
self.f['local_user1'].refresh_from_db()
|
||||||
|
self.assertTrue(ToolshedUser.objects.filter(username='testuser1').exists())
|
||||||
|
self.assertIsNone(self.f['local_user1'].profile_picture)
|
||||||
|
|
||||||
|
self.assertFalse(InventoryItem.global_objects.filter(owner_id=self.f['local_user1'].id).exists())
|
||||||
|
self.assertFalse(StorageLocation.objects.filter(owner_id=self.f['local_user1'].id).exists())
|
||||||
|
self.assertFalse(AccountPreference.objects.filter(user_id=self.f['local_user1'].id).exists())
|
||||||
|
self.assertEqual(self.f['local_user1'].public_identity.friends.count(), 0)
|
||||||
|
|
||||||
|
# orphaned file (only referenced by the deleted user/items) is gone
|
||||||
|
self.assertFalse(File.objects.filter(hash='orphan').exists())
|
||||||
|
# file still referenced by the other user's item survives
|
||||||
|
self.assertTrue(File.objects.filter(hash='shared').exists())
|
||||||
|
|
||||||
|
# the other user's data and identity/friend relation to the deleted identity are untouched
|
||||||
|
self.f['local_user2'].refresh_from_db()
|
||||||
|
self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
def test_delete_account_anonymous(self):
|
||||||
|
response = anonymous_client.delete('/api/account/')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_delete_account_removes_data_and_closes_account(self):
|
||||||
|
user1_id = self.f['local_user1'].id
|
||||||
|
identity_id = self.f['local_user1'].public_identity_id
|
||||||
|
|
||||||
|
response = client.delete('/api/account/', self.f['local_user1'])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
summary = response.json()
|
||||||
|
self.assertEqual(summary['inventory_items'], 1)
|
||||||
|
self.assertEqual(summary['locations'], 4)
|
||||||
|
self.assertEqual(summary['settings'], 1)
|
||||||
|
self.assertEqual(summary['friends'], 1)
|
||||||
|
self.assertTrue(summary['account'])
|
||||||
|
|
||||||
|
# the account itself is gone
|
||||||
|
self.assertFalse(ToolshedUser.objects.filter(id=user1_id).exists())
|
||||||
|
self.assertFalse(InventoryItem.global_objects.filter(owner_id=user1_id).exists())
|
||||||
|
self.assertFalse(StorageLocation.objects.filter(owner_id=user1_id).exists())
|
||||||
|
self.assertFalse(AccountPreference.objects.filter(user_id=user1_id).exists())
|
||||||
|
|
||||||
|
# the underlying identity is kept, so remote friends/history referencing it stay intact
|
||||||
|
from authentication.models import KnownIdentity
|
||||||
|
self.assertTrue(KnownIdentity.objects.filter(id=identity_id).exists())
|
||||||
|
|
||||||
|
# orphaned file (only referenced by the deleted user/items) is gone
|
||||||
|
self.assertFalse(File.objects.filter(hash='orphan').exists())
|
||||||
|
# file still referenced by the other user's item survives
|
||||||
|
self.assertTrue(File.objects.filter(hash='shared').exists())
|
||||||
|
|
||||||
|
# the other user's data and identity/friend relation to the deleted identity are untouched
|
||||||
|
self.f['local_user2'].refresh_from_db()
|
||||||
|
self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
|
||||||
|
self.assertEqual(self.f['local_user2'].public_identity.friends.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
||||||
|
ToolshedTestCase):
|
||||||
|
"""Properties must round-trip through export/import even when their value contains a
|
||||||
|
comma or an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of
|
||||||
|
the 'properties' CSV cell would misinterpret as a field/entry separator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_categories()
|
||||||
|
self.prepare_tags()
|
||||||
|
self.prepare_properties()
|
||||||
|
|
||||||
|
def test_property_values_with_comma_and_equals_round_trip(self):
|
||||||
|
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='widget')
|
||||||
|
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
|
||||||
|
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
|
||||||
|
|
||||||
|
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
|
||||||
|
|
||||||
|
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(imported, 1)
|
||||||
|
|
||||||
|
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='widget')
|
||||||
|
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
|
||||||
|
self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})
|
||||||
|
|
||||||
|
def test_legacy_comma_equals_format_is_still_importable(self):
|
||||||
|
handle1 = self.f['prop1'].get_handle()
|
||||||
|
handle2 = self.f['prop2'].get_handle()
|
||||||
|
csv_data = (
|
||||||
|
'name,properties\r\n'
|
||||||
|
f'legacy widget,"{handle1}=value1, {handle2}=value2"\r\n'
|
||||||
|
).encode('utf-8')
|
||||||
|
|
||||||
|
imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(imported, 1)
|
||||||
|
|
||||||
|
item = InventoryItem.objects.get(owner=self.f['local_user1'], name='legacy widget')
|
||||||
|
values = {ip.property.name: ip.value for ip in item.itemproperty_set.select_related('property')}
|
||||||
|
self.assertEqual(values, {'prop1': 'value1', 'prop2': 'value2'})
|
||||||
|
|
||||||
|
def test_item_without_properties_imports_cleanly(self):
|
||||||
|
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='bare item')
|
||||||
|
|
||||||
|
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
|
||||||
|
|
||||||
|
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(imported, 1)
|
||||||
|
|
||||||
|
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='bare item')
|
||||||
|
self.assertEqual(list(new_item.itemproperty_set.all()), [])
|
||||||
|
|
||||||
|
def test_category_and_tags_round_trip(self):
|
||||||
|
item = InventoryItem.objects.create(
|
||||||
|
owner=self.f['local_user1'], name='cat and tags item', category=self.f['cat1'])
|
||||||
|
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
|
||||||
|
|
||||||
|
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
|
||||||
|
|
||||||
|
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(imported, 1)
|
||||||
|
|
||||||
|
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='cat and tags item')
|
||||||
|
self.assertEqual(new_item.category, self.f['cat1'])
|
||||||
|
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
|
||||||
|
|
||||||
|
def test_unknown_property_handle_skips_item_with_error(self):
|
||||||
|
csv_data = (
|
||||||
|
'name,properties\r\n'
|
||||||
|
'ghost widget,test#property:doesnotexist=x\r\n'
|
||||||
|
).encode('utf-8')
|
||||||
|
|
||||||
|
imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
|
||||||
|
self.assertEqual(imported, 0)
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
self.assertIn('doesnotexist', errors[0])
|
||||||
|
self.assertFalse(InventoryItem.objects.filter(owner=self.f['local_user1'], name='ghost widget').exists())
|
||||||
|
|
||||||
|
def test_property_value_with_quote_character_round_trips(self):
|
||||||
|
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='quoted widget')
|
||||||
|
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='12" screen')
|
||||||
|
|
||||||
|
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
|
||||||
|
|
||||||
|
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(imported, 1)
|
||||||
|
|
||||||
|
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='quoted widget')
|
||||||
|
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
|
||||||
|
self.assertEqual(values, {'prop1': '12" screen'})
|
||||||
|
|
||||||
|
|
||||||
|
class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
||||||
|
ToolshedTestCase):
|
||||||
|
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by
|
||||||
|
clients), rather than calling the internal helper functions directly - this is what a real
|
||||||
|
export/import round trip between two accounts looks like.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_categories()
|
||||||
|
self.prepare_tags()
|
||||||
|
self.prepare_properties()
|
||||||
|
|
||||||
|
def test_export_then_import_preserves_category_tags_and_properties(self):
|
||||||
|
import base64
|
||||||
|
|
||||||
|
item = InventoryItem.objects.create(
|
||||||
|
owner=self.f['local_user1'], name='drill', description='cordless drill',
|
||||||
|
category=self.f['cat1'], availability_policy='friends', owned_quantity=2)
|
||||||
|
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
|
||||||
|
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
|
||||||
|
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
|
||||||
|
|
||||||
|
export_reply = client.get('/api/export/', self.f['local_user1'])
|
||||||
|
self.assertEqual(export_reply.status_code, 200)
|
||||||
|
zip_bytes = export_reply.content
|
||||||
|
|
||||||
|
import_reply = client.post('/api/import/', self.f['local_user2'],
|
||||||
|
{'zip': base64.b64encode(zip_bytes).decode('ascii')})
|
||||||
|
self.assertEqual(import_reply.status_code, 200)
|
||||||
|
summary = import_reply.json()
|
||||||
|
self.assertEqual(summary['inventory_items'], 1)
|
||||||
|
self.assertEqual(summary['errors'], [])
|
||||||
|
|
||||||
|
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='drill')
|
||||||
|
self.assertEqual(new_item.category, self.f['cat1'])
|
||||||
|
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
|
||||||
|
|
||||||
|
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
|
||||||
|
self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})
|
||||||
|
|
||||||
48
backend/toolshed/tests/test_workflow_api.py
Normal file
48
backend/toolshed/tests/test_workflow_api.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import json
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase
|
||||||
|
from toolshed.tests import WorkflowTestMixin
|
||||||
|
from toolshed.models import WorkflowInstance
|
||||||
|
|
||||||
|
anonymous_client = Client()
|
||||||
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTestCase):
|
||||||
|
"""Comprehensive test cases for the Workflow API"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_workflows()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workflow_instances(self):
|
||||||
|
reply = client.get('/api/workflows/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(reply.data), 3)
|
||||||
|
self.assertEqual(reply.data[0]['name'], 'workflow1')
|
||||||
|
self.assertEqual(reply.data[1]['name'], 'workflow1')
|
||||||
|
self.assertEqual(reply.data[2]['name'], 'workflow1')
|
||||||
|
self.assertEqual(reply.data[0]['state'], 'initial')
|
||||||
|
self.assertEqual(reply.data[1]['state'], 'upload')
|
||||||
|
self.assertEqual(reply.data[2]['state'], 'describe')
|
||||||
|
self.assertEqual(json.loads(reply.data[0]['payload']), {})
|
||||||
|
self.assertEqual(json.loads(reply.data[1]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']})
|
||||||
|
self.assertEqual(json.loads(reply.data[2]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2',
|
||||||
|
'a1b2c3d4e5f60718293a4b5c6d7e8f90',
|
||||||
|
'b1c2d3e4f5a60718293b4c5d6e7f8090'],
|
||||||
|
'descriptions': ['file 1 description']})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_workflow_instances_user2(self):
|
||||||
|
reply = client.get('/api/workflows/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(reply.data), 1)
|
||||||
|
self.assertEqual(reply.data[0]['name'], 'workflow2')
|
||||||
|
self.assertEqual(reply.data[0]['state'], 'initial')
|
||||||
|
self.assertEqual(json.loads(reply.data[0]['payload']), {})
|
||||||
|
|
||||||
83
cli-client/toolshed-client.py
Executable file
83
cli-client/toolshed-client.py
Executable file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from nacl.signing import SigningKey
|
||||||
|
from json import dumps
|
||||||
|
|
||||||
|
|
||||||
|
class ToolshedApi:
|
||||||
|
user = None
|
||||||
|
host = None
|
||||||
|
signing_key = None
|
||||||
|
|
||||||
|
def __init__(self, user, host, key):
|
||||||
|
if host is None:
|
||||||
|
raise ValueError("TOOLSHED_HOST environment variable not set")
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("TOOLSHED_USER environment variable not set")
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
raise ValueError("TOOLSHED_KEY environment variable not set")
|
||||||
|
|
||||||
|
if len(key) != 64:
|
||||||
|
raise ValueError("TOOLSHED_KEY must be 64 hex characters")
|
||||||
|
|
||||||
|
signing_key = SigningKey(bytes.fromhex(key))
|
||||||
|
|
||||||
|
self.user = user
|
||||||
|
self.host = host
|
||||||
|
self.signing_key = signing_key
|
||||||
|
|
||||||
|
def get(self, target):
|
||||||
|
url = "http://" + self.host + target
|
||||||
|
signed = self.signing_key.sign(url.encode('utf-8'))
|
||||||
|
signature = signed.signature.hex()
|
||||||
|
response = requests.get(url, headers={"Authorization": "Signature " + self.user + ":" + signature})
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def post(self, target, data):
|
||||||
|
url = "http://" + self.host + target
|
||||||
|
json = dumps(data)
|
||||||
|
signed = self.signing_key.sign(url.encode('utf-8') + json.encode('utf-8'))
|
||||||
|
signature = signed.signature.hex()
|
||||||
|
response = requests.post(url, headers={"Authorization": "Signature " + self.user + ":" + signature}, json=data)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
host = os.environ.get('TOOLSHED_HOST')
|
||||||
|
user = os.environ.get('TOOLSHED_USER')
|
||||||
|
key = os.environ.get('TOOLSHED_KEY')
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='Toolshed API client')
|
||||||
|
parser.add_argument('--host', help='Toolshed host')
|
||||||
|
parser.add_argument('--user', help='Toolshed user')
|
||||||
|
parser.add_argument('--key', help='Toolshed key')
|
||||||
|
parser.add_argument('cmd', help='Command')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.host is not None:
|
||||||
|
host = args.host
|
||||||
|
|
||||||
|
if args.user is not None:
|
||||||
|
user = args.user
|
||||||
|
|
||||||
|
if args.key is not None:
|
||||||
|
key = args.key
|
||||||
|
|
||||||
|
api = ToolshedApi(user, host, key)
|
||||||
|
|
||||||
|
if args.cmd == 'getinventory':
|
||||||
|
inv = api.get("/api/inventory_items/")
|
||||||
|
print(inv)
|
||||||
|
elif args.cmd == 'additem':
|
||||||
|
inv = api.post("/api/inventory_items/", {"name": "test"})
|
||||||
|
print(inv)
|
||||||
|
else:
|
||||||
|
print("Unknown command: " + args.cmd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -13,4 +13,4 @@ COPY requirements.txt /code/
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--insecure"]
|
CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure"]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ WORKDIR /app
|
||||||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
|
COPY extras/ ./extras/
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
CMD [ "npm", "run", "dev", "--", "--host"]
|
CMD [ "npm", "run", "dev", "--", "--host"]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
|
|
||||||
|
SERVE_X_ACCEL_REDIRECT=False
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY='e*lm&*!j0_stqaiod$1zob(vs@aq6+n-i$1%!rek)_v9n^ue$3'
|
SECRET_KEY='e*lm&*!j0_stqaiod$1zob(vs@aq6+n-i$1%!rek)_v9n^ue$3'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ http {
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream dns {
|
upstream dns {
|
||||||
server dns:8053;
|
server toolshed-dns:8053;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
|
@ -45,8 +45,34 @@ http {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /media {
|
||||||
|
proxy_set_header Host $host:$server_port;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_pass http://backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /djangoadmin {
|
||||||
|
proxy_set_header Host $host:$server_port;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_pass http://backend;
|
||||||
|
}
|
||||||
|
|
||||||
location /docs {
|
location /docs {
|
||||||
proxy_pass http://backend/docs;
|
proxy_set_header Host $host:$server_port;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_pass http://backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /static {
|
location /static {
|
||||||
|
|
@ -80,17 +106,35 @@ http {
|
||||||
# DoH server
|
# DoH server
|
||||||
server {
|
server {
|
||||||
listen 5353 ssl;
|
listen 5353 ssl;
|
||||||
server_name localhost;
|
server_name localhost 127.0.0.3;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/nginx.crt;
|
ssl_certificate /etc/nginx/nginx.crt;
|
||||||
ssl_certificate_key /etc/nginx/nginx.key;
|
ssl_certificate_key /etc/nginx/nginx.key;
|
||||||
|
|
||||||
location /dns-query {
|
# Ensure CORS headers are present even when nginx generates 5xx responses.
|
||||||
proxy_pass http://dns;
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
# allow any origin
|
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||||
add_header 'Access-Control-Allow-Origin' '*';
|
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
|
add_header 'Access-Control-Expose-Headers' 'Content-Type' always;
|
||||||
|
error_page 500 502 503 504 = @doh_error;
|
||||||
|
|
||||||
|
location /dns-query {
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
|
||||||
|
add_header 'Access-Control-Max-Age' 86400 always;
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_pass http://dns;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @doh_error {
|
||||||
|
default_type text/plain;
|
||||||
|
return 502 'DoH upstream unavailable';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ def create_test_data():
|
||||||
if KnownIdentity.objects.filter(username='test_b').exists():
|
if KnownIdentity.objects.filter(username='test_b').exists():
|
||||||
identity = KnownIdentity.objects.get(username='test_b')
|
identity = KnownIdentity.objects.get(username='test_b')
|
||||||
identity = KnownIdentity.objects.create(username='test_b', domain='b.localhost',
|
identity = KnownIdentity.objects.create(username='test_b', domain='b.localhost',
|
||||||
public_key='4b0dffe21764c591762615ef84cfea7fd4055fab3e9f58ae077fd8c79c34af91')
|
public_key='14c44f03c3a0406934cf3a27b0eeedae8a08a3f436690c7103eca13435172a8c')
|
||||||
# pk '2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40'
|
# pk '2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40'
|
||||||
identity.save()
|
identity.save()
|
||||||
print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain,
|
print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
|
|
||||||
|
SERVE_X_ACCEL_REDIRECT=True
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe'
|
SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,28 @@ http {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /media {
|
||||||
|
proxy_set_header Host $host:$server_port;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_pass http://backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /redirect_media/ {
|
||||||
|
internal;
|
||||||
|
alias /var/www/userfiles/;
|
||||||
|
# This location serves the file directly, bypassing Django (and therefore
|
||||||
|
# django-cors-headers) entirely - it's the target of the X-Accel-Redirect
|
||||||
|
# response from files/media_urls.py, used when SERVE_X_ACCEL_REDIRECT=True.
|
||||||
|
# CORS headers must be added here explicitly since nothing else will.
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'Authorization, Accept, Content-Type, Origin, User-Agent' always;
|
||||||
|
}
|
||||||
|
|
||||||
location /docs {
|
location /docs {
|
||||||
proxy_pass http://backend/docs;
|
proxy_pass http://backend/docs;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,4 @@
|
||||||
"target": "127.0.0.2."
|
"target": "127.0.0.2."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -10,6 +10,7 @@ services:
|
||||||
- ../deploy/dev/instance_a/a.env:/code/.env
|
- ../deploy/dev/instance_a/a.env:/code/.env
|
||||||
- ../deploy/dev/instance_a/testdata.py:/code/testdata.py
|
- ../deploy/dev/instance_a/testdata.py:/code/testdata.py
|
||||||
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
|
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
|
||||||
|
- ../deploy/dev/instance_a/userfiles:/code/userfiles
|
||||||
expose:
|
expose:
|
||||||
- 8000
|
- 8000
|
||||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||||
|
|
@ -23,6 +24,7 @@ services:
|
||||||
- ../deploy/dev/instance_b/b.env:/code/.env
|
- ../deploy/dev/instance_b/b.env:/code/.env
|
||||||
- ../deploy/dev/instance_b/testdata.py:/code/testdata.py
|
- ../deploy/dev/instance_b/testdata.py:/code/testdata.py
|
||||||
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
|
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
|
||||||
|
- ../deploy/dev/instance_b/userfiles:/code/userfiles
|
||||||
expose:
|
expose:
|
||||||
- 8000
|
- 8000
|
||||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||||
|
|
@ -57,6 +59,7 @@ services:
|
||||||
- ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
|
- ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./dev/instance_a/dns.json:/var/www/dns.json:ro
|
- ./dev/instance_a/dns.json:/var/www/dns.json:ro
|
||||||
- ./dev/instance_a/domains.json:/var/www/domains.json:ro
|
- ./dev/instance_a/domains.json:/var/www/domains.json:ro
|
||||||
|
- ./dev/instance_a/userfiles:/var/www/userfiles:ro
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
- "127.0.0.3:5353:5353"
|
- "127.0.0.3:5353:5353"
|
||||||
|
|
@ -67,6 +70,7 @@ services:
|
||||||
dockerfile: dev/Dockerfile.proxy
|
dockerfile: dev/Dockerfile.proxy
|
||||||
volumes:
|
volumes:
|
||||||
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
|
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./dev/instance_b/userfiles:/var/www/userfiles:ro
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.2:8080:8080"
|
- "127.0.0.2:8080:8080"
|
||||||
|
|
||||||
|
|
@ -78,3 +82,7 @@ services:
|
||||||
- ./dev/zone.json:/dns/zone.json
|
- ./dev/zone.json:/dns/zone.json
|
||||||
expose:
|
expose:
|
||||||
- 8053
|
- 8053
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases:
|
||||||
|
- toolshed-dns
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
version: '3.3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# db:
|
|
||||||
# image: postgres
|
|
||||||
# container_name: docker-django-vue-db
|
|
||||||
# environment:
|
|
||||||
# POSTGRES_USER: user
|
|
||||||
# POSTGRES_PASSWORD: pass
|
|
||||||
# POSTGRES_DB: db
|
|
||||||
# restart: unless-stopped
|
|
||||||
# ports:
|
|
||||||
# - "5432:5432"
|
|
||||||
django:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
command: python backend/manage.py runserver 0.0.0.0:8000
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
ports:
|
|
||||||
- "8002:8000"
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
# depends_on:
|
|
||||||
# - db
|
|
||||||
vue:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
command: nginx -g 'daemon off;'
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
ports:
|
|
||||||
- "8001:80"
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
- external
|
|
||||||
depends_on:
|
|
||||||
- django
|
|
||||||
networks:
|
|
||||||
external:
|
|
||||||
internal:
|
|
||||||
|
|
@ -97,6 +97,12 @@ Start the fullstack application:
|
||||||
docker-compose -f deploy/docker-compose.override.yml up --build
|
docker-compose -f deploy/docker-compose.override.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run backend tests in Docker:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
docker compose -f deploy/docker-compose.override.yml run --rm backend-a bash -lc "python configure.py && python manage.py test"
|
||||||
|
```
|
||||||
|
|
||||||
This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend.
|
This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend.
|
||||||
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
|
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
|
||||||
server is used to direct the frontend to the correct backend instance.
|
server is used to direct the frontend to the correct backend instance.
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDLzCCAhegAwIBAgIUJmAh7fVmxDGKqLmRepxu/JqgEcgwDQYJKoZIhvcNAQEL
|
|
||||||
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
|
|
||||||
MzExMDMwMDI4NDNaFw0yNjA4MjMwMDI4NDNaMCcxCzAJBgNVBAYTAlVTMRgwFgYD
|
|
||||||
VQQDDA9FeGFtcGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
|
||||||
AoIBAQDE/lXJfHolXhbd7gevY0+AurY4iMAUx+FDbdpRaKc4z+hHANKWR5OZBwIj
|
|
||||||
mI6RNpe/yq+ZE7fDnQ5mq9BuD37i9ZR3TiFQQ0K6yVHJs1xn64Ce4dhz0LxIU+mR
|
|
||||||
rWYTMDkfjOnKi6ueycgKSzk6GnCgSix0ho4yRj8CPO9HzKf7bgHJZzh//xFk3Cbo
|
|
||||||
EgHGXxnm5yCfBthaU5Toey8AYlHlH/OVYTSxu2yRu/HdWbVglqdSu2us7U9SG88V
|
|
||||||
EeJf5fSwmyYS4ab9JZUhM7HgMVQZIx/Gq+VoYZCs6s8/Bm3hnFQDVHDQ9mAL9cZB
|
|
||||||
EvOgQGcEFXHS6SXkWwWpYietmNdPAgMBAAGjUzBRMB0GA1UdDgQWBBTCPiGSDMoi
|
|
||||||
nNiPFsW4fYZW3XDPKjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAP
|
|
||||||
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAQw65jeCi5mNPmw4Uw
|
|
||||||
BYEmGfzv7bYMrDu8w1A2U2yFa+6BN17mAMLJQxzcrvP4/DK1MaP1ehGfWRH2jzSb
|
|
||||||
m5CgbZYDVBib2jD1HlMFEBvEF2wduQL9mIipeYXFIS9ttuW7QVqso62Na/3tcp9J
|
|
||||||
dCfHsbnAvWSt63/7d9wHX3zlBN7v9GeJbXo14p8aA8IEjQFQWnkjhvRVzU/fltKm
|
|
||||||
dvG/xkXA59dqXMEYxHyGK2tE4WiaLwsQW44LQcuEjy4NSj5t0F4NWYVLUgIgnqk8
|
|
||||||
RkGEXAxcRfFXSF6VEBTxoAAhTh5zSge4PSwHT+29dzkJqzpFb6cCke4Skk16KiW8
|
|
||||||
0dg1
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDE/lXJfHolXhbd
|
|
||||||
7gevY0+AurY4iMAUx+FDbdpRaKc4z+hHANKWR5OZBwIjmI6RNpe/yq+ZE7fDnQ5m
|
|
||||||
q9BuD37i9ZR3TiFQQ0K6yVHJs1xn64Ce4dhz0LxIU+mRrWYTMDkfjOnKi6ueycgK
|
|
||||||
Szk6GnCgSix0ho4yRj8CPO9HzKf7bgHJZzh//xFk3CboEgHGXxnm5yCfBthaU5To
|
|
||||||
ey8AYlHlH/OVYTSxu2yRu/HdWbVglqdSu2us7U9SG88VEeJf5fSwmyYS4ab9JZUh
|
|
||||||
M7HgMVQZIx/Gq+VoYZCs6s8/Bm3hnFQDVHDQ9mAL9cZBEvOgQGcEFXHS6SXkWwWp
|
|
||||||
YietmNdPAgMBAAECggEADKTgf77AHk7kO9lv39eJflM9p/JLFepgcJIH/cqwVQqv
|
|
||||||
N4N7gM7WT++tuPgtSm/NHK/XRoWH+YPGrVtg1Pekps+peGDo7Xxrt459c+tRxo/M
|
|
||||||
oSYQ3RosQ54zMzkEgL3LJNXIGcqOOPJtpw99/Fc/HGK2aMJMNTjiCXQ8zYh6zb38
|
|
||||||
himWC1Vysb6tO4bQ/zJeMuh+ny6oAu0x8CAKJfFHwm/enA2ab2VGT9L/S/Mv7bqh
|
|
||||||
WmRsOrY7nnSSHhYFBr3VcvC4yzsQ+UI9LKllK1M2Lb7JVrGkgYwJFqiwomV5bRDn
|
|
||||||
QP4gFgDvgghsQgm9Fx7ZTC6vIXpgEZQAiSPkIVB7LQKBgQDiO9Db4Jp1h46Bu+je
|
|
||||||
JW3MUw8QNHRnlTmJr0bIu1IDq+3LBLJUo7wR2+/JxNx/v4R+qSRv65HEFF5QwuID
|
|
||||||
o+F2vZ08xHKY1uKuXV26VpRohuQVPz+PhUExYE29RhAUbGq/14wbH8vIpUNk5VKW
|
|
||||||
9yxAFZjqKyqhsSdL6C23i2AT6wKBgQDe6aDwRP0WXDsdgdiXiEXXZ47RyE6h9/8+
|
|
||||||
ehx++rMHME2C6fL562MHBiwfZWoy3X/hlCahvOMGSPT93sqgKATYdYWzwQnOIyUG
|
|
||||||
LIH2ZfUStKnnK+yNMh4sUZZERDy+JSzvIULtxNJ/m4ai0FNTobAqw4WMXKUTKKOL
|
|
||||||
rSgRPkVFLQKBgQDVlQMpknJAYyn28SB4GX3/WKrv8ZWz9P3NLtz0xWQ4cyiccmKp
|
|
||||||
5aq4cZuAMYq6UGUV7zmnMA79vv0L4fDe3ukM4RaBODW4daasVxkVwod551+Nb5ji
|
|
||||||
NjvGEdVSy8KChZiCZUywjuQlGWD28El4nQH5vmoPKZGl9UnUL+55Sev64QKBgAOv
|
|
||||||
AqzVxwTmk0eCjMy8QMM1f+OxlrMQgRRMOkpS5EB7KNy/9vbwT9ue233NVDrw9BsL
|
|
||||||
MrQ7hdjlvb6wPtL0tlfJYWM+DmZsqED43VDwEGO1vpUGYctXh64dzwjjw8k0nhB5
|
|
||||||
kTySoxPewxPPs4DPTDT4tC1Enqm5YzJ8swvN5qElAoGABPP5g+EekI2eo6xPIsVf
|
|
||||||
FPhto+WKn7Hxx+sQZBg7EuX8QLxrGBy9yeU0BMcyuvQYPHbGtfqlItVchBk7Nxug
|
|
||||||
s82jJgWYymbroKvCYShAnQujZuyjI62qJq1gfvfcLT4g+k3nZ+KZwCc4iusOELlH
|
|
||||||
0rnQsA6KSyCpKB51T1iUkZQ=
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDLzCCAhegAwIBAgIUJmAh7fVmxDGKqLmRepxu/JqgEcgwDQYJKoZIhvcNAQEL
|
|
||||||
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
|
|
||||||
MzExMDMwMDI4NDNaFw0yNjA4MjMwMDI4NDNaMCcxCzAJBgNVBAYTAlVTMRgwFgYD
|
|
||||||
VQQDDA9FeGFtcGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
|
||||||
AoIBAQDE/lXJfHolXhbd7gevY0+AurY4iMAUx+FDbdpRaKc4z+hHANKWR5OZBwIj
|
|
||||||
mI6RNpe/yq+ZE7fDnQ5mq9BuD37i9ZR3TiFQQ0K6yVHJs1xn64Ce4dhz0LxIU+mR
|
|
||||||
rWYTMDkfjOnKi6ueycgKSzk6GnCgSix0ho4yRj8CPO9HzKf7bgHJZzh//xFk3Cbo
|
|
||||||
EgHGXxnm5yCfBthaU5Toey8AYlHlH/OVYTSxu2yRu/HdWbVglqdSu2us7U9SG88V
|
|
||||||
EeJf5fSwmyYS4ab9JZUhM7HgMVQZIx/Gq+VoYZCs6s8/Bm3hnFQDVHDQ9mAL9cZB
|
|
||||||
EvOgQGcEFXHS6SXkWwWpYietmNdPAgMBAAGjUzBRMB0GA1UdDgQWBBTCPiGSDMoi
|
|
||||||
nNiPFsW4fYZW3XDPKjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAP
|
|
||||||
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAQw65jeCi5mNPmw4Uw
|
|
||||||
BYEmGfzv7bYMrDu8w1A2U2yFa+6BN17mAMLJQxzcrvP4/DK1MaP1ehGfWRH2jzSb
|
|
||||||
m5CgbZYDVBib2jD1HlMFEBvEF2wduQL9mIipeYXFIS9ttuW7QVqso62Na/3tcp9J
|
|
||||||
dCfHsbnAvWSt63/7d9wHX3zlBN7v9GeJbXo14p8aA8IEjQFQWnkjhvRVzU/fltKm
|
|
||||||
dvG/xkXA59dqXMEYxHyGK2tE4WiaLwsQW44LQcuEjy4NSj5t0F4NWYVLUgIgnqk8
|
|
||||||
RkGEXAxcRfFXSF6VEBTxoAAhTh5zSge4PSwHT+29dzkJqzpFb6cCke4Skk16KiW8
|
|
||||||
0dg1
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
0C2D2D6E0BCB8B8AFD205E5E4A616C11D96DD566
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
authorityKeyIdentifier=keyid,issuer
|
|
||||||
basicConstraints=CA:FALSE
|
|
||||||
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
|
|
||||||
subjectAltName = @alt_names
|
|
||||||
[alt_names]
|
|
||||||
DNS.1 = localhost
|
|
||||||
#DNS.2 = fake1.local
|
|
||||||
#DNS.3 = fake2.local
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDkjCCAnqgAwIBAgIUDC0tbgvLi4r9IF5eSmFsEdlt1WYwDQYJKoZIhvcNAQEL
|
|
||||||
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
|
|
||||||
MzExMDMwMDMxMDFaFw0yNjA4MjMwMDMxMDFaMG0xCzAJBgNVBAYTAlVTMRIwEAYD
|
|
||||||
VQQIDAlZb3VyU3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFt
|
|
||||||
cGxlLUNlcnRpZmljYXRlczEYMBYGA1UEAwwPbG9jYWxob3N0LmxvY2FsMIIBIjAN
|
|
||||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwUYH95U11TYvYn9or/cP/oPtiJJm
|
|
||||||
l1aH5J2AOfno/QW5FjDKeGUYeXrGTOLeDRiHIAHVtg00nfTtm56du5+I972VbEW6
|
|
||||||
ngrQAGyk/GSvOGK1iG4M4eHpCkZHrdkubOWsO5vDj2juTwjE1fyncLmIbyZN5NKJ
|
|
||||||
6JID8c/ItFXQzXDLaFAQH3+uFFX9iTeT2cGyFEidooKo4oKj3imQRM6YKl4ygEMc
|
|
||||||
BTN0p7AqtjKSfva5WsMSXdESLsNdStgs5OgfJUE8x4jhcrVrZ2Mnw8Zmfb2pIoNe
|
|
||||||
6HphVtU96EMDqZmRJ4c9REqS6qZmHKsAPet/jyNNZjUJIptJE6Cl0IihhwIDAQAB
|
|
||||||
o3AwbjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAJBgNVHRMEAjAA
|
|
||||||
MAsGA1UdDwQEAwIE8DAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFIni
|
|
||||||
f5YpRjm9GEjktq2gr/34isB1MA0GCSqGSIb3DQEBCwUAA4IBAQCAv0FdWz+Ktq6+
|
|
||||||
I7wOUWx3LHFQSov71ca9HGbD9SMOBbd26MJXnxxjOU62W+Q/Bkx4C3ezzSuLMhZq
|
|
||||||
Vl3jV2wDkWWCFvR1a2cwmr05B2xs/lqX+eNwrNZNfGxMV4I4dn+bkGe2czRcZaur
|
|
||||||
et0SylTQvWwrCgewRirS44LcN9EEutu0WzxlUpdty71zlVKdpyE6MrZ10ADcin5B
|
|
||||||
P/10++aBuQXF1EDFGZ6snexGuSsshAP2tBLww+Z8xPyN3SRMj/Y0z7Rgm4nhEfNP
|
|
||||||
M1BcUiaT7e8evsWVAyoDI6N1Z4M0p4SXLLeHdhKelbvNdeVZxwDkl8jR+Q5hwt7K
|
|
||||||
ZurEL1FZ
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDLzCCAhegAwIBAgIUJmAh7fVmxDGKqLmRepxu/JqgEcgwDQYJKoZIhvcNAQEL
|
|
||||||
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
|
|
||||||
MzExMDMwMDI4NDNaFw0yNjA4MjMwMDI4NDNaMCcxCzAJBgNVBAYTAlVTMRgwFgYD
|
|
||||||
VQQDDA9FeGFtcGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
|
||||||
AoIBAQDE/lXJfHolXhbd7gevY0+AurY4iMAUx+FDbdpRaKc4z+hHANKWR5OZBwIj
|
|
||||||
mI6RNpe/yq+ZE7fDnQ5mq9BuD37i9ZR3TiFQQ0K6yVHJs1xn64Ce4dhz0LxIU+mR
|
|
||||||
rWYTMDkfjOnKi6ueycgKSzk6GnCgSix0ho4yRj8CPO9HzKf7bgHJZzh//xFk3Cbo
|
|
||||||
EgHGXxnm5yCfBthaU5Toey8AYlHlH/OVYTSxu2yRu/HdWbVglqdSu2us7U9SG88V
|
|
||||||
EeJf5fSwmyYS4ab9JZUhM7HgMVQZIx/Gq+VoYZCs6s8/Bm3hnFQDVHDQ9mAL9cZB
|
|
||||||
EvOgQGcEFXHS6SXkWwWpYietmNdPAgMBAAGjUzBRMB0GA1UdDgQWBBTCPiGSDMoi
|
|
||||||
nNiPFsW4fYZW3XDPKjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAP
|
|
||||||
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAQw65jeCi5mNPmw4Uw
|
|
||||||
BYEmGfzv7bYMrDu8w1A2U2yFa+6BN17mAMLJQxzcrvP4/DK1MaP1ehGfWRH2jzSb
|
|
||||||
m5CgbZYDVBib2jD1HlMFEBvEF2wduQL9mIipeYXFIS9ttuW7QVqso62Na/3tcp9J
|
|
||||||
dCfHsbnAvWSt63/7d9wHX3zlBN7v9GeJbXo14p8aA8IEjQFQWnkjhvRVzU/fltKm
|
|
||||||
dvG/xkXA59dqXMEYxHyGK2tE4WiaLwsQW44LQcuEjy4NSj5t0F4NWYVLUgIgnqk8
|
|
||||||
RkGEXAxcRfFXSF6VEBTxoAAhTh5zSge4PSwHT+29dzkJqzpFb6cCke4Skk16KiW8
|
|
||||||
0dg1
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDkjCCAnqgAwIBAgIUDC0tbgvLi4r9IF5eSmFsEdlt1WYwDQYJKoZIhvcNAQEL
|
|
||||||
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
|
|
||||||
MzExMDMwMDMxMDFaFw0yNjA4MjMwMDMxMDFaMG0xCzAJBgNVBAYTAlVTMRIwEAYD
|
|
||||||
VQQIDAlZb3VyU3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFt
|
|
||||||
cGxlLUNlcnRpZmljYXRlczEYMBYGA1UEAwwPbG9jYWxob3N0LmxvY2FsMIIBIjAN
|
|
||||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwUYH95U11TYvYn9or/cP/oPtiJJm
|
|
||||||
l1aH5J2AOfno/QW5FjDKeGUYeXrGTOLeDRiHIAHVtg00nfTtm56du5+I972VbEW6
|
|
||||||
ngrQAGyk/GSvOGK1iG4M4eHpCkZHrdkubOWsO5vDj2juTwjE1fyncLmIbyZN5NKJ
|
|
||||||
6JID8c/ItFXQzXDLaFAQH3+uFFX9iTeT2cGyFEidooKo4oKj3imQRM6YKl4ygEMc
|
|
||||||
BTN0p7AqtjKSfva5WsMSXdESLsNdStgs5OgfJUE8x4jhcrVrZ2Mnw8Zmfb2pIoNe
|
|
||||||
6HphVtU96EMDqZmRJ4c9REqS6qZmHKsAPet/jyNNZjUJIptJE6Cl0IihhwIDAQAB
|
|
||||||
o3AwbjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAJBgNVHRMEAjAA
|
|
||||||
MAsGA1UdDwQEAwIE8DAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFIni
|
|
||||||
f5YpRjm9GEjktq2gr/34isB1MA0GCSqGSIb3DQEBCwUAA4IBAQCAv0FdWz+Ktq6+
|
|
||||||
I7wOUWx3LHFQSov71ca9HGbD9SMOBbd26MJXnxxjOU62W+Q/Bkx4C3ezzSuLMhZq
|
|
||||||
Vl3jV2wDkWWCFvR1a2cwmr05B2xs/lqX+eNwrNZNfGxMV4I4dn+bkGe2czRcZaur
|
|
||||||
et0SylTQvWwrCgewRirS44LcN9EEutu0WzxlUpdty71zlVKdpyE6MrZ10ADcin5B
|
|
||||||
P/10++aBuQXF1EDFGZ6snexGuSsshAP2tBLww+Z8xPyN3SRMj/Y0z7Rgm4nhEfNP
|
|
||||||
M1BcUiaT7e8evsWVAyoDI6N1Z4M0p4SXLLeHdhKelbvNdeVZxwDkl8jR+Q5hwt7K
|
|
||||||
ZurEL1FZ
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
-----BEGIN CERTIFICATE REQUEST-----
|
|
||||||
MIICsjCCAZoCAQAwbTELMAkGA1UEBhMCVVMxEjAQBgNVBAgMCVlvdXJTdGF0ZTER
|
|
||||||
MA8GA1UEBwwIWW91ckNpdHkxHTAbBgNVBAoMFEV4YW1wbGUtQ2VydGlmaWNhdGVz
|
|
||||||
MRgwFgYDVQQDDA9sb2NhbGhvc3QubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IB
|
|
||||||
DwAwggEKAoIBAQDBRgf3lTXVNi9if2iv9w/+g+2IkmaXVofknYA5+ej9BbkWMMp4
|
|
||||||
ZRh5esZM4t4NGIcgAdW2DTSd9O2bnp27n4j3vZVsRbqeCtAAbKT8ZK84YrWIbgzh
|
|
||||||
4ekKRket2S5s5aw7m8OPaO5PCMTV/KdwuYhvJk3k0onokgPxz8i0VdDNcMtoUBAf
|
|
||||||
f64UVf2JN5PZwbIUSJ2igqjigqPeKZBEzpgqXjKAQxwFM3SnsCq2MpJ+9rlawxJd
|
|
||||||
0RIuw11K2Czk6B8lQTzHiOFytWtnYyfDxmZ9vakig17oemFW1T3oQwOpmZEnhz1E
|
|
||||||
SpLqpmYcqwA963+PI01mNQkim0kToKXQiKGHAgMBAAGgADANBgkqhkiG9w0BAQsF
|
|
||||||
AAOCAQEAZUPJaPBXjSsbDoWEREiOrbai0sVRa3Qr/IIz3Z/+Nw9m2E+j9O11Vi8G
|
|
||||||
G3eqkixw/m4StbKZyMg+PAQlLC5NUmgMC5w9lfODq/CSRY8UVUi/EiIAMI4Rh/Dk
|
|
||||||
rGVsYArCcsmbWUzB5oUw4oUtgv0fWsuM0Thc7GORvtQvq4xzPcpWa1L88kRA08Mv
|
|
||||||
P8Yyk3txYQlkk3Bd40UL8TN6XiQC2IF1b/kXizqr2HL+1BiqCDUWtdDZmVZTvUXH
|
|
||||||
chUn1tCEm0iJSJZsJLVa5CDWkZHat2A3+8SaxYRL6QlWbiISOhs7KL6fPeTxc/S8
|
|
||||||
OKHnA3FRDzQTp+lpjSJWJEqt5W3i+Q==
|
|
||||||
-----END CERTIFICATE REQUEST-----
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDBRgf3lTXVNi9i
|
|
||||||
f2iv9w/+g+2IkmaXVofknYA5+ej9BbkWMMp4ZRh5esZM4t4NGIcgAdW2DTSd9O2b
|
|
||||||
np27n4j3vZVsRbqeCtAAbKT8ZK84YrWIbgzh4ekKRket2S5s5aw7m8OPaO5PCMTV
|
|
||||||
/KdwuYhvJk3k0onokgPxz8i0VdDNcMtoUBAff64UVf2JN5PZwbIUSJ2igqjigqPe
|
|
||||||
KZBEzpgqXjKAQxwFM3SnsCq2MpJ+9rlawxJd0RIuw11K2Czk6B8lQTzHiOFytWtn
|
|
||||||
YyfDxmZ9vakig17oemFW1T3oQwOpmZEnhz1ESpLqpmYcqwA963+PI01mNQkim0kT
|
|
||||||
oKXQiKGHAgMBAAECgf9k4w6I+HUuySeHJ+06PhtsELUINvfCPaxhFaAc2TwWNgGC
|
|
||||||
XfWjTrZ323ZJaMTsEReFWkdqLDciqKoA8AEVQCQxmY7fVA1mwt/QtzZ1lrq3zx+w
|
|
||||||
Ci3yrZvCBJzES0kmgXmpo+i0eh8T3waqkcwz1XzON3PP8J1qoOiWoe1Yawd4oMIq
|
|
||||||
QN0/DE1Zdz23QtWo0OLZw0/tLpB3cTK2XpzpBLh+OLImLmA3mqhbsJKzoVD79MTF
|
|
||||||
emgjFumsZqf0+jUU6tn2+XP83dxeL4yRQGfI19Qg0g+AF4Ue39/7Zife127h7x8o
|
|
||||||
iknk0m1WSyXjNNxFk1hceHz0CE1qWh/OxuWK+lkCgYEA9uxcmcSYvqMBQKd/q33r
|
|
||||||
v9wB5jHOPNGXapawKDTCW6B77bvKiYwP0wGNcIV0gDqFdt1RnS4OC3ERoiuhgZum
|
|
||||||
C7m2r/WOF+ktpkdMaiOLnrtPcAeZKItALAzTqAsbEDHupuqMvQ9Rlk33K/p+SsMt
|
|
||||||
YXEU0lbe7nsTVHUwZIEnymMCgYEAyGDOTUTJjWIL6x2n7kIWo+kxvCtL7rSmS6LY
|
|
||||||
XgyE7YTAbptEPtSG4iZTwp2mV6teeakIJ8he6Qw+DlMI+25tDjODMiX2CTbDUPD7
|
|
||||||
Koz/7rvX1KvqLGDocik3dzNEgimHamn9edR4he+yOuVNQTeQKyT2kiTS13PDUSyx
|
|
||||||
c511A40CgYALcE+p3+2jhzGbqSbE7wRo0uxTmaMQNjVpfqmScz89t2WGUdjEHnTt
|
|
||||||
NXkz6TrIZe2g4AfT3DpSVV6XwKWut2Mtz+CiriFzAh5XQZ5MnHDvXYYEjpWNx0Wl
|
|
||||||
xMJZWopaabXAjcUPTRJegCYfJ2+KTxhfFzn7tIKHUbRHiVZAkasiOQKBgQC0yGJY
|
|
||||||
5E7T+Dm4jrEMDSRQDfyJQKbpXqMKaom7kJ/9i1/yarNP3H81Wk8HryR8xGgga5io
|
|
||||||
C+dVa24xjkj0T9Skk2UywX0RIw9k34Y8BaqCwYcgoVpNX8FX/kfEi8SrirxaDfZb
|
|
||||||
DI8QiAnNiUQCKxcvVdIAWKjBKfh2LFSNcuo3oQKBgDuFJRHTBOkSTFIImVoxAV8i
|
|
||||||
duZrLuhgrzyLAuH5kMNGW8hm3dHYRMFm86nrWyMQgY52dar0TNzbPRvy2qU/2L8g
|
|
||||||
LxFrVO3pw5uZbYrcuhWAqLJCkJTDqawHvLGy5L5YJHewxBUszNHJIaluh0MvlJev
|
|
||||||
c+jFMCSN9BLo6Fy50vM8
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
cd $(dirname $0)
|
|
||||||
|
|
||||||
rf -rf RootCA.*
|
|
||||||
|
|
||||||
openssl req -x509 -nodes -new -sha256 -days 1024 -newkey rsa:2048 -keyout RootCA.key -out RootCA.pem -subj "/C=US/CN=Example-Root-CA"
|
|
||||||
openssl x509 -outform pem -in RootCA.pem -out RootCA.crt
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
cd $(dirname $0)
|
|
||||||
|
|
||||||
rf -rf localhost.*
|
|
||||||
|
|
||||||
openssl req -new -nodes -newkey rsa:2048 -keyout localhost.key -out localhost.csr -subj "/C=US/ST=YourState/L=YourCity/O=Example-Certificates/CN=localhost.local"
|
|
||||||
openssl x509 -req -sha256 -days 1024 -in localhost.csr -CA RootCA.pem -CAkey RootCA.key -CAcreateserial -extfile domains.ext -out localhost.crt
|
|
||||||
|
|
||||||
cat localhost.crt RootCA.crt > fullchain.pem
|
|
||||||
cat localhost.key > privkey.pem
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDBRgf3lTXVNi9i
|
|
||||||
f2iv9w/+g+2IkmaXVofknYA5+ej9BbkWMMp4ZRh5esZM4t4NGIcgAdW2DTSd9O2b
|
|
||||||
np27n4j3vZVsRbqeCtAAbKT8ZK84YrWIbgzh4ekKRket2S5s5aw7m8OPaO5PCMTV
|
|
||||||
/KdwuYhvJk3k0onokgPxz8i0VdDNcMtoUBAff64UVf2JN5PZwbIUSJ2igqjigqPe
|
|
||||||
KZBEzpgqXjKAQxwFM3SnsCq2MpJ+9rlawxJd0RIuw11K2Czk6B8lQTzHiOFytWtn
|
|
||||||
YyfDxmZ9vakig17oemFW1T3oQwOpmZEnhz1ESpLqpmYcqwA963+PI01mNQkim0kT
|
|
||||||
oKXQiKGHAgMBAAECgf9k4w6I+HUuySeHJ+06PhtsELUINvfCPaxhFaAc2TwWNgGC
|
|
||||||
XfWjTrZ323ZJaMTsEReFWkdqLDciqKoA8AEVQCQxmY7fVA1mwt/QtzZ1lrq3zx+w
|
|
||||||
Ci3yrZvCBJzES0kmgXmpo+i0eh8T3waqkcwz1XzON3PP8J1qoOiWoe1Yawd4oMIq
|
|
||||||
QN0/DE1Zdz23QtWo0OLZw0/tLpB3cTK2XpzpBLh+OLImLmA3mqhbsJKzoVD79MTF
|
|
||||||
emgjFumsZqf0+jUU6tn2+XP83dxeL4yRQGfI19Qg0g+AF4Ue39/7Zife127h7x8o
|
|
||||||
iknk0m1WSyXjNNxFk1hceHz0CE1qWh/OxuWK+lkCgYEA9uxcmcSYvqMBQKd/q33r
|
|
||||||
v9wB5jHOPNGXapawKDTCW6B77bvKiYwP0wGNcIV0gDqFdt1RnS4OC3ERoiuhgZum
|
|
||||||
C7m2r/WOF+ktpkdMaiOLnrtPcAeZKItALAzTqAsbEDHupuqMvQ9Rlk33K/p+SsMt
|
|
||||||
YXEU0lbe7nsTVHUwZIEnymMCgYEAyGDOTUTJjWIL6x2n7kIWo+kxvCtL7rSmS6LY
|
|
||||||
XgyE7YTAbptEPtSG4iZTwp2mV6teeakIJ8he6Qw+DlMI+25tDjODMiX2CTbDUPD7
|
|
||||||
Koz/7rvX1KvqLGDocik3dzNEgimHamn9edR4he+yOuVNQTeQKyT2kiTS13PDUSyx
|
|
||||||
c511A40CgYALcE+p3+2jhzGbqSbE7wRo0uxTmaMQNjVpfqmScz89t2WGUdjEHnTt
|
|
||||||
NXkz6TrIZe2g4AfT3DpSVV6XwKWut2Mtz+CiriFzAh5XQZ5MnHDvXYYEjpWNx0Wl
|
|
||||||
xMJZWopaabXAjcUPTRJegCYfJ2+KTxhfFzn7tIKHUbRHiVZAkasiOQKBgQC0yGJY
|
|
||||||
5E7T+Dm4jrEMDSRQDfyJQKbpXqMKaom7kJ/9i1/yarNP3H81Wk8HryR8xGgga5io
|
|
||||||
C+dVa24xjkj0T9Skk2UywX0RIw9k34Y8BaqCwYcgoVpNX8FX/kfEi8SrirxaDfZb
|
|
||||||
DI8QiAnNiUQCKxcvVdIAWKjBKfh2LFSNcuo3oQKBgDuFJRHTBOkSTFIImVoxAV8i
|
|
||||||
duZrLuhgrzyLAuH5kMNGW8hm3dHYRMFm86nrWyMQgY52dar0TNzbPRvy2qU/2L8g
|
|
||||||
LxFrVO3pw5uZbYrcuhWAqLJCkJTDqawHvLGy5L5YJHewxBUszNHJIaluh0MvlJev
|
|
||||||
c+jFMCSN9BLo6Fy50vM8
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 98d7936a50a148e0c2c718b3d7b8662ffcb3fde8
|
Subproject commit 21bf28a8b6293872f5f7a42c9ca0bc12ede5aaae
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIEXTCCA0WgAwIBAgISBEHk0Sh8UrMfT+VPkRhr83mfMA0GCSqGSIb3DQEBCwUA
|
|
||||||
MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD
|
|
||||||
EwJSMzAeFw0yMzA2MDIxNzI1MDVaFw0yMzA4MzExNzI1MDRaMBsxGTAXBgNVBAMT
|
|
||||||
EHRvb2xzaGVkLmozZDEuZGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYeMpT
|
|
||||||
k1DaC8cigL3DivanGrLQahYBEDm5B26VaS3gUmq9T0RNkEUxJIPnZBwdF8p7xAEB
|
|
||||||
hlTXwgy3eBLAp8lAo4ICTTCCAkkwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQG
|
|
||||||
CCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTRtqOn
|
|
||||||
Qht7rD+2IEGzuYt8frTgVjAfBgNVHSMEGDAWgBQULrMXt1hWy65QCUDmH6+dixTC
|
|
||||||
xjBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6Ly9yMy5vLmxlbmNy
|
|
||||||
Lm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL3IzLmkubGVuY3Iub3JnLzAbBgNVHREE
|
|
||||||
FDASghB0b29sc2hlZC5qM2QxLmRlMEwGA1UdIARFMEMwCAYGZ4EMAQIBMDcGCysG
|
|
||||||
AQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5cHQu
|
|
||||||
b3JnMIIBBgYKKwYBBAHWeQIEAgSB9wSB9ADyAHcAtz77JN+cTbp18jnFulj0bF38
|
|
||||||
Qs96nzXEnh0JgSXttJkAAAGIfVskxgAABAMASDBGAiEA+D8rCaCpttJm7w0M4N5N
|
|
||||||
3cmJSfPNmh/t2ojaDB0iSe0CIQCS2XkwJzUrDZ35fIJ9evwJduk/K2I/tmWs4Uk5
|
|
||||||
vnPSNQB3AK33vvp8/xDIi509nB4+GGq0Zyldz7EMJMqFhjTr3IKKAAABiH1bJOoA
|
|
||||||
AAQDAEgwRgIhAPHQQwLf5xSi1VH6BeOpiUKyTMawd36FFU8eCIdB43q6AiEA0KDD
|
|
||||||
yRssPcmGnyWDGq9Of3mpKjCChFrnxzpeDXCTlsswDQYJKoZIhvcNAQELBQADggEB
|
|
||||||
AKo8APReSKNTydks9yqASKhUjuLfXS+mpFQSl2tbU8ER6eIiYHx8o+n2QCdT7h91
|
|
||||||
ZLkGx8ZAmWBvVwXC3QPH5W08ilogi4EU/+HGffkditG5K6/Qn2bzjqnmFIyYqgdT
|
|
||||||
RVaRcxqS9byAGEw3oU5FSCIOuFSBeOHeTwaj+lSVMZTv6LmoovOpCo8sA5xZ6K6H
|
|
||||||
XVwNXIwunssaR4MrnWupB/5N+T7zkhanky4GgiLRuTm+mDbK+OIDx47Hv9jTe+tm
|
|
||||||
s4aixD0eWhzAaiA7HuHJI3Xi64YjK7eNlrwE0ZKdgy8KveDwUBiVcVtz7LR+0v1l
|
|
||||||
P27Z/OkZlA+42LvIJdISMl4=
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
|
|
||||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
|
||||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
|
|
||||||
WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
|
|
||||||
RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
|
||||||
AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
|
|
||||||
R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
|
|
||||||
sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
|
|
||||||
NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
|
|
||||||
Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
|
|
||||||
/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
|
|
||||||
AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
|
|
||||||
Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
|
|
||||||
FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
|
|
||||||
AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
|
|
||||||
Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
|
|
||||||
gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
|
|
||||||
PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
|
|
||||||
ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
|
|
||||||
CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
|
|
||||||
lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
|
|
||||||
avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
|
|
||||||
yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
|
|
||||||
yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
|
|
||||||
hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
|
|
||||||
HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
|
|
||||||
MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
|
|
||||||
nLRbwHOoq7hHwg==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/
|
|
||||||
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
|
|
||||||
DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow
|
|
||||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
|
||||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB
|
|
||||||
AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC
|
|
||||||
ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL
|
|
||||||
wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D
|
|
||||||
LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK
|
|
||||||
4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5
|
|
||||||
bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y
|
|
||||||
sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ
|
|
||||||
Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4
|
|
||||||
FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc
|
|
||||||
SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql
|
|
||||||
PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND
|
|
||||||
TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
|
|
||||||
SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1
|
|
||||||
c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx
|
|
||||||
+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB
|
|
||||||
ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu
|
|
||||||
b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E
|
|
||||||
U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu
|
|
||||||
MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC
|
|
||||||
5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW
|
|
||||||
9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG
|
|
||||||
WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O
|
|
||||||
he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC
|
|
||||||
Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html index.htm;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api {
|
|
||||||
proxy_pass http://django:8000;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
2391
frontend/package-lock.json
generated
2391
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,10 @@
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.2.47",
|
||||||
"vue-multiselect": "^2.1.7",
|
"vue-multiselect": "^2.1.7",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
"vuex": "^4.1.0"
|
"vuex": "^4.1.0",
|
||||||
|
"extras": "file:./extras",
|
||||||
|
"jquery": "^3.6.0",
|
||||||
|
"popper.js": "^1.16.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.0.0",
|
"@vitejs/plugin-vue": "^4.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgD6EmCAUWob1zUw4Q
|
|
||||||
F+Pf9cSOmSCTODe6u+Gst177IoihRANCAARYeMpTk1DaC8cigL3DivanGrLQahYB
|
|
||||||
EDm5B26VaS3gUmq9T0RNkEUxJIPnZBwdF8p7xAEBhlTXwgy3eBLAp8lA
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
|
|
@ -3,23 +3,13 @@
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
<!-- TODO UI für Freunde liste, add, remove -->
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
|
||||||
import {mapMutations} from 'vuex';
|
|
||||||
import store from '@/store';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App'
|
||||||
methods: {
|
|
||||||
...mapMutations(['init']),
|
|
||||||
},
|
|
||||||
beforeCreate () {
|
|
||||||
store.commit('load_local')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue