Compare commits
47 commits
cfcc2c15d3
...
00f7ba0b19
| Author | SHA1 | Date | |
|---|---|---|---|
| 00f7ba0b19 | |||
| 5ebd26119f | |||
| e03303ace9 | |||
| c81a179c40 | |||
| 7512ba76da | |||
| 751f3deaab | |||
| c7c91f87ba | |||
|
|
a85c3c3296 | ||
|
|
fe2b6e9f68 | ||
|
|
04e8cbf5f8 | ||
| a1da771507 | |||
| fa7d5f40a9 | |||
| a7515d366c | |||
| 0b46fb152c | |||
| 6248a7ef2f | |||
| 034c4a9ee0 | |||
| 53517e5170 | |||
| 0d4012b4c6 | |||
| b45841fff8 | |||
| 012cf00e8e | |||
| 1a96caee1f | |||
| 7e1b0c7e49 | |||
| 5ede5e7b98 | |||
| 27d21545fc | |||
| 6ada06af52 | |||
| d28a2eacf1 | |||
| 509dfb24ec | |||
| cecc03a820 | |||
| 5c6d560680 | |||
| fda9f8e70f | |||
| 3774a2f438 | |||
| 2c77077859 | |||
| 7b1a081cdd | |||
| d4fd270aa2 | |||
| df70a347e3 | |||
| 34942cedda | |||
| c31ad9f354 | |||
| d582b29d7a | |||
| b614891816 | |||
| 6c65da0882 | |||
| 765a265e46 | |||
| 1b69cf5ef6 | |||
| ce63ce368f | |||
| a92fbbf855 | |||
| a5ca19ddf8 | |||
| e89c261f56 | |||
| ff372205d9 |
174 changed files with 2915 additions and 10269 deletions
|
|
@ -13,5 +13,4 @@ steps:
|
|||
- apk add --no-cache gcc musl-dev python3-dev
|
||||
- pip install --upgrade pip && pip install -r requirements.txt
|
||||
- python3 configure.py
|
||||
- coverage run manage.py test
|
||||
- coverage report
|
||||
- coverage run --parallel-mode --concurrency=multiprocessing manage.py test --parallel=$(nproc) && coverage report
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
# toolshed
|
||||
|
||||
|
||||
## foo
|
||||
|
||||
## Development
|
||||
|
||||
``` bash
|
||||
|
|
@ -90,4 +87,4 @@ for detailed instructions see [docs](/docs/deployment.md).
|
|||
|
||||
``` bash
|
||||
cli-client/toolshed-client.py --key <hex private key> --user name@example.com --host 1.2.3.4:8000 getinventory
|
||||
```
|
||||
```
|
||||
|
|
@ -1,4 +1,2 @@
|
|||
|
||||
ALLOWED_HOSTS="localhost,127.0.0.1"
|
||||
|
||||
SERVE_X_ACCEL_REDIRECT=True
|
||||
ALLOWED_HOSTS="localhost,127.0.0.1"
|
||||
|
|
@ -6,7 +6,7 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
|
|||
COPY . /app
|
||||
RUN python configure.py
|
||||
RUN python manage.py collectstatic --noinput
|
||||
CMD python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure
|
||||
CMD python manage.py runserver 0.0.0.0:8000 --insecure
|
||||
# TODO serve static files with nginx and remove --insecure
|
||||
EXPOSE 8000
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
||||
AccountPreference
|
||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming
|
||||
|
||||
|
||||
class ToolshedUserAdmin(admin.ModelAdmin):
|
||||
|
|
@ -9,11 +8,6 @@ class ToolshedUserAdmin(admin.ModelAdmin):
|
|||
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):
|
||||
list_display = ('username', 'domain', 'public_key')
|
||||
search_fields = ('username', 'domain', 'public_key')
|
||||
|
|
@ -33,4 +27,3 @@ admin.site.register(ToolshedUser, ToolshedUserAdmin)
|
|||
admin.site.register(KnownIdentity, KnownIdentityAdmin)
|
||||
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
||||
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
|
||||
admin.site.register(AccountPreference, AccountPreferenceAdmin)
|
||||
|
|
|
|||
|
|
@ -9,57 +9,12 @@ from rest_framework.authtoken.models import Token
|
|||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentication.models import ToolshedUser, AccountPreference
|
||||
from authentication.signature_auth import SignatureAuthenticationLocal, SignatureAuthentication, \
|
||||
split_userhandle_or_throw
|
||||
from files.models import File
|
||||
from files.serializers import FileSerializer
|
||||
from authentication.models import ToolshedUser
|
||||
from authentication.signature_auth import SignatureAuthenticationLocal
|
||||
from hostadmin.models import Domain
|
||||
|
||||
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):
|
||||
|
||||
|
|
@ -98,70 +53,15 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
|
||||
|
||||
@api_view(['GET', 'PATCH'])
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
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
|
||||
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({
|
||||
'username': user.username,
|
||||
'domain': user.domain,
|
||||
'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,
|
||||
'email': user.email
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -199,51 +99,11 @@ def registerUser(request):
|
|||
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)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('user/', getUserInfo),
|
||||
path('user/<str:handle>/', getUserProfile),
|
||||
path('register/', registerUser),
|
||||
path('token/', UserAuthToken.as_view()),
|
||||
path('preferences/', preference_definitions),
|
||||
path('self/preferences/', account_preferences),
|
||||
path('self/preferences/<str:key>/', account_preference_detail),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
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'),
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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,8 +86,6 @@ class ToolshedUser(AbstractUser):
|
|||
domain = models.CharField(max_length=255, default='localhost')
|
||||
private_key = models.CharField(max_length=255)
|
||||
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()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -113,23 +111,6 @@ class ToolshedUser(AbstractUser):
|
|||
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):
|
||||
secret = models.CharField(max_length=255)
|
||||
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')
|
||||
|
|
|
|||
|
|
@ -106,19 +106,11 @@ def authenticate_request_against_local_users(request, raw_request_body):
|
|||
|
||||
class SignatureAuthentication(authentication.BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
|
||||
# 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
|
||||
return authenticate_request_against_known_identities(
|
||||
request, request.body.decode('utf-8')), None
|
||||
|
||||
|
||||
class SignatureAuthenticationLocal(authentication.BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
user = authenticate_request_against_local_users(request, request.body.decode('utf-8'))
|
||||
if user is None:
|
||||
return None
|
||||
return user, None
|
||||
return authenticate_request_against_local_users(
|
||||
request, request.body.decode('utf-8')), None
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import json
|
||||
import base64
|
||||
|
||||
from django.test import Client, RequestFactory
|
||||
from nacl.encoding import HexEncoder
|
||||
|
|
@ -7,7 +6,6 @@ from nacl.signing import SigningKey
|
|||
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
|
||||
from files.models import File
|
||||
|
||||
|
||||
class AuthorizationTestCase(ToolshedTestCase):
|
||||
|
|
@ -242,7 +240,6 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||
self.assertEqual(reply.json()['email'], 'test1@abc.de')
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
|
||||
def test_user_info2(self):
|
||||
target = "/auth/user/"
|
||||
|
|
@ -252,50 +249,6 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
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'])
|
||||
|
||||
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):
|
||||
reply = self.anonymous_client.get('/auth/user/')
|
||||
|
|
@ -355,52 +308,6 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
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):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
|
|||
|
|
@ -86,8 +86,6 @@ CORS_ALLOW_ALL_ORIGINS = True
|
|||
USE_X_FORWARDED_HOST = True
|
||||
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'
|
||||
|
||||
TEMPLATES = [
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ urlpatterns = [
|
|||
path('api/', include('toolshed.api.inventory')),
|
||||
path('api/', include('toolshed.api.info')),
|
||||
path('api/', include('toolshed.api.files')),
|
||||
path('api/', include('toolshed.api.offlinedata')),
|
||||
path('media/', include('files.media_urls')),
|
||||
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
from django.http import HttpResponse
|
||||
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 rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||
|
|
@ -17,30 +15,16 @@ from files.models import File
|
|||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
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:
|
||||
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 = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get(
|
||||
file=hash_path)
|
||||
|
||||
if settings.SERVE_X_ACCEL_REDIRECT:
|
||||
return HttpResponse(status=status.HTTP_200_OK,
|
||||
content_type=file.mime_type,
|
||||
headers={
|
||||
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
||||
}) # 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())
|
||||
|
||||
return HttpResponse(status=status.HTTP_200_OK,
|
||||
content_type=file.mime_type,
|
||||
headers={
|
||||
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}) # TODO Expires and Cache-Control
|
||||
|
||||
except File.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import DefaultStorage
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.test import Client, override_settings
|
||||
from django.test import Client
|
||||
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
|
||||
from toolshed.tests import InventoryTestMixin
|
||||
from nacl.hash import sha256
|
||||
|
|
@ -120,28 +120,28 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
|||
self.f['item2'].files.add(self.f['test_file1'])
|
||||
|
||||
|
||||
# def test_file_url(self):
|
||||
# reply = client.get(
|
||||
# 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.assertEqual(reply.status_code, 200)
|
||||
# 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:]}")
|
||||
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
|
||||
# reply = client.get(
|
||||
# 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.assertEqual(reply.status_code, 200)
|
||||
# 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:]}")
|
||||
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
||||
# reply = client.get(
|
||||
# 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.assertEqual(reply.status_code, 200)
|
||||
# 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:]}")
|
||||
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
||||
def test_file_url(self):
|
||||
reply = client.get(
|
||||
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.assertEqual(reply.status_code, 200)
|
||||
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:]}")
|
||||
self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
|
||||
reply = client.get(
|
||||
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.assertEqual(reply.status_code, 200)
|
||||
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:]}")
|
||||
self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
||||
reply = client.get(
|
||||
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.assertEqual(reply.status_code, 200)
|
||||
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:]}")
|
||||
self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
||||
|
||||
def test_file_url_fail(self):
|
||||
reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1'])
|
||||
|
|
@ -165,33 +165,3 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
|||
self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
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,10 +41,6 @@ class DomainSerializer(serializers.ModelSerializer):
|
|||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
if 'name' in attrs:
|
||||
|
|
@ -60,17 +56,13 @@ class CategorySerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = ['name', 'description', 'parent', 'origin', 'handle']
|
||||
read_only_fields = ['origin', 'handle']
|
||||
fields = ['name', 'description', 'parent', 'origin']
|
||||
read_only_fields = ['origin']
|
||||
ref_name = 'HostAdminCategory'
|
||||
|
||||
|
||||
class PropertySerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
if 'name' in attrs:
|
||||
|
|
@ -87,17 +79,13 @@ class PropertySerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = Property
|
||||
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix',
|
||||
'dimensions', 'origin', 'handle']
|
||||
read_only_fields = ['origin', 'handle']
|
||||
'dimensions', 'origin']
|
||||
read_only_fields = ['origin']
|
||||
ref_name = 'HostAdminProperty'
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
if 'name' in attrs:
|
||||
|
|
@ -113,6 +101,6 @@ class TagSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['name', 'description', 'category', 'origin', 'handle']
|
||||
read_only_fields = ['origin', 'handle']
|
||||
fields = ['name', 'description', 'category', 'origin']
|
||||
read_only_fields = ['origin']
|
||||
ref_name = 'HostAdminTag'
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
{
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
{
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
{
|
||||
"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,39 +1,11 @@
|
|||
from django.contrib import admin
|
||||
|
||||
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',)
|
||||
from toolshed.models import InventoryItem, Property, Tag, Category
|
||||
|
||||
|
||||
class InventoryItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('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())
|
||||
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner')
|
||||
search_fields = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner')
|
||||
|
||||
|
||||
admin.site.register(InventoryItem, InventoryItemAdmin)
|
||||
|
|
@ -41,7 +13,7 @@ admin.site.register(InventoryItem, InventoryItemAdmin)
|
|||
|
||||
class PropertyAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
|
||||
search_fields = ('name', 'description', 'category__name', 'unit_symbol', 'origin')
|
||||
search_fields = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
|
||||
|
||||
|
||||
admin.site.register(Property, PropertyAdmin)
|
||||
|
|
@ -49,7 +21,7 @@ admin.site.register(Property, PropertyAdmin)
|
|||
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'description', 'category', 'origin')
|
||||
search_fields = ('name', 'description', 'category__name', 'origin')
|
||||
search_fields = ('name', 'description', 'category', 'origin')
|
||||
|
||||
|
||||
admin.site.register(Tag, TagAdmin)
|
||||
|
|
@ -57,26 +29,7 @@ admin.site.register(Tag, TagAdmin)
|
|||
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'description', 'parent', 'origin')
|
||||
search_fields = ('name', 'description', 'parent__name', 'origin')
|
||||
search_fields = ('name', 'description', 'parent', 'origin')
|
||||
|
||||
|
||||
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.urls import path
|
||||
from rest_framework import routers, viewsets, status
|
||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
|
||||
from rest_framework import routers, viewsets
|
||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.signature_auth import SignatureAuthentication
|
||||
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
|
||||
from toolshed.models import InventoryItem, StorageLocation
|
||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
|
||||
|
|
@ -22,8 +22,7 @@ def inventory_items(identity):
|
|||
except ToolshedUser.DoesNotExist:
|
||||
pass
|
||||
for friend in identity.friends.all():
|
||||
friend_user = friend.user.first()
|
||||
if friend_user:
|
||||
if friend_user := friend.user.get():
|
||||
for item in friend_user.inventory_items.all():
|
||||
if item.availability_policy != 'private':
|
||||
yield item
|
||||
|
|
@ -53,25 +52,13 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
|||
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'])
|
||||
@authentication_classes([SignatureAuthentication])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def search_inventory_items(request):
|
||||
query = request.query_params.get('query')
|
||||
if query:
|
||||
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(InventoryItemSerializer(inventory_items(request.user), many=True).data)
|
||||
return Response({'error': 'No query provided.'}, status=400)
|
||||
|
||||
|
||||
|
|
@ -85,47 +72,9 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
|
|||
return StorageLocation.objects.filter(owner=self.request.user.user.get())
|
||||
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'storage_locations', StorageLocationViewSet, basename='storage_locations')
|
||||
router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
|
||||
|
||||
urlpatterns = router.urls + [
|
||||
path('search/', search_inventory_items, name='search_inventory_items'),
|
||||
|
|
|
|||
|
|
@ -1,253 +0,0 @@
|
|||
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'),
|
||||
]
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
# 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# 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.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django_softdelete.models import SoftDeleteModel
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
|
@ -26,10 +26,6 @@ class Category(SoftDeleteModel):
|
|||
parent = str(self.parent) + "/" if self.parent else ""
|
||||
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):
|
||||
name = models.CharField(max_length=255)
|
||||
|
|
@ -54,10 +50,6 @@ class Property(models.Model):
|
|||
def __str__(self):
|
||||
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):
|
||||
name = models.CharField(max_length=255)
|
||||
|
|
@ -77,10 +69,6 @@ class Tag(models.Model):
|
|||
def __str__(self):
|
||||
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):
|
||||
AVAILABILITY_POLICY_CHOICES = (
|
||||
|
|
@ -102,7 +90,7 @@ class InventoryItem(SoftDeleteModel):
|
|||
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
|
||||
properties = models.ManyToManyField(Property, through='ItemProperty')
|
||||
files = models.ManyToManyField(File, related_name='connected_items')
|
||||
storage_location = models.ForeignKey('StorageLocation', on_delete=models.SET_NULL, null=True, blank=True,
|
||||
storage_location = models.ForeignKey('StorageLocation', on_delete=models.CASCADE, null=True, blank=True,
|
||||
related_name='inventory_items')
|
||||
|
||||
def clean(self):
|
||||
|
|
@ -132,17 +120,3 @@ class StorageLocation(models.Model):
|
|||
def __str__(self):
|
||||
parent = str(self.parent) + "/" if self.parent else ""
|
||||
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})"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,670 +0,0 @@
|
|||
"""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,47 +3,7 @@ from authentication.models import KnownIdentity, ToolshedUser, FriendRequestInco
|
|||
from authentication.serializers import OwnerSerializer
|
||||
from files.models import File
|
||||
from files.serializers import FileSerializer
|
||||
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)
|
||||
|
||||
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation
|
||||
|
||||
|
||||
class FriendSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -69,45 +29,33 @@ class FriendRequestSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class PropertySerializer(serializers.ModelSerializer):
|
||||
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()
|
||||
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
|
||||
|
||||
class Meta:
|
||||
model = Property
|
||||
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix', 'handle']
|
||||
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix']
|
||||
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
handle = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = ['name', 'handle']
|
||||
|
||||
def get_handle(self, obj):
|
||||
return obj.get_handle()
|
||||
fields = ['name']
|
||||
|
||||
def to_representation(self, instance):
|
||||
return instance.name
|
||||
return str(instance)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return resolve_category_handle(data.split("/")[-1])
|
||||
return Category.objects.get(name=data.split("/")[-1])
|
||||
|
||||
|
||||
class StorageLocationSerializer(serializers.ModelSerializer):
|
||||
owner = OwnerSerializer(read_only=True)
|
||||
category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
category = CategorySerializer(required=False, allow_null=True)
|
||||
path = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = StorageLocation
|
||||
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
|
||||
fields = ['id', 'name', 'description', 'path', 'category', 'owner']
|
||||
read_only_fields = ['path']
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -119,28 +67,23 @@ class StorageLocationSerializer(serializers.ModelSerializer):
|
|||
|
||||
class ItemPropertySerializer(serializers.ModelSerializer):
|
||||
property = PropertySerializer(read_only=True)
|
||||
handle = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ItemProperty
|
||||
fields = ['property', 'value', 'handle']
|
||||
|
||||
def get_handle(self, obj):
|
||||
return obj.property.get_handle()
|
||||
fields = ['property', 'value']
|
||||
|
||||
def to_representation(self, instance):
|
||||
return {'value': instance.value, 'name': instance.property.name}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
prop = resolve_property_handle(data.get('name') or data.get('handle'))
|
||||
prop = Property.objects.get(name=data['name'])
|
||||
value = data['value']
|
||||
return {'property': prop, 'value': value}
|
||||
|
||||
|
||||
class InventoryItemSerializer(serializers.ModelSerializer):
|
||||
owner = OwnerSerializer(read_only=True)
|
||||
tags = serializers.SerializerMethodField()
|
||||
tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
|
||||
tags = serializers.SlugRelatedField(many=True, required=False, queryset=Tag.objects.all(), slug_field='name')
|
||||
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
||||
category = CategorySerializer(required=False, allow_null=True)
|
||||
files = FileSerializer(many=True, read_only=True)
|
||||
|
|
@ -148,16 +91,11 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
|
||||
'tags', 'tags_input', 'properties', 'files', 'storage_location']
|
||||
|
||||
def get_tags(self, obj):
|
||||
return [tag.name for tag in obj.tags.all()]
|
||||
'tags', 'properties', 'files', 'storage_location']
|
||||
|
||||
def to_internal_value(self, data):
|
||||
files = data.pop('files', [])
|
||||
tags_input = data.pop('tags_input', data.pop('tags', []))
|
||||
ret = super().to_internal_value(data)
|
||||
ret['tags'] = [resolve_tag_handle(tag) for tag in tags_input]
|
||||
ret['files'] = files
|
||||
return ret
|
||||
|
||||
|
|
@ -200,12 +138,3 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
|||
ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value'])
|
||||
item.save()
|
||||
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,5 +1,4 @@
|
|||
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance
|
||||
import json
|
||||
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation
|
||||
|
||||
|
||||
class CategoryTestMixin:
|
||||
|
|
@ -55,33 +54,3 @@ class LocationTestMixin:
|
|||
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'],
|
||||
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 files.tests import FilesTestMixin
|
||||
from toolshed.models import InventoryItem, Category, StorageLocation
|
||||
from toolshed.models import InventoryItem, Category
|
||||
from toolshed.tests import InventoryTestMixin, LocationTestMixin
|
||||
|
||||
client = SignatureAuthClient()
|
||||
|
|
@ -69,63 +69,3 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.assertEqual(reply.json()[3]['description'], None)
|
||||
self.assertEqual(reply.json()[3]['category'], 'cat1')
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,264 +0,0 @@
|
|||
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'})
|
||||
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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']), {})
|
||||
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
#!/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 the application
|
||||
CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure"]
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--insecure"]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ WORKDIR /app
|
|||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
||||
COPY package.json ./
|
||||
|
||||
COPY extras/ ./extras/
|
||||
RUN npm install
|
||||
|
||||
CMD [ "npm", "run", "dev", "--", "--host"]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG=True
|
||||
|
||||
SERVE_X_ACCEL_REDIRECT=False
|
||||
|
||||
# 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'
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ http {
|
|||
}
|
||||
|
||||
upstream dns {
|
||||
server toolshed-dns:8053;
|
||||
server dns:8053;
|
||||
}
|
||||
|
||||
server {
|
||||
|
|
@ -45,34 +45,8 @@ http {
|
|||
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 {
|
||||
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;
|
||||
proxy_pass http://backend/docs;
|
||||
}
|
||||
|
||||
location /static {
|
||||
|
|
@ -106,35 +80,17 @@ http {
|
|||
# DoH server
|
||||
server {
|
||||
listen 5353 ssl;
|
||||
server_name localhost 127.0.0.3;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate /etc/nginx/nginx.crt;
|
||||
ssl_certificate_key /etc/nginx/nginx.key;
|
||||
|
||||
# Ensure CORS headers are present even when nginx generates 5xx responses.
|
||||
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-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;
|
||||
}
|
||||
# allow any origin
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
|
||||
|
||||
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():
|
||||
identity = KnownIdentity.objects.get(username='test_b')
|
||||
identity = KnownIdentity.objects.create(username='test_b', domain='b.localhost',
|
||||
public_key='14c44f03c3a0406934cf3a27b0eeedae8a08a3f436690c7103eca13435172a8c')
|
||||
public_key='4b0dffe21764c591762615ef84cfea7fd4055fab3e9f58ae077fd8c79c34af91')
|
||||
# pk '2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40'
|
||||
identity.save()
|
||||
print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG=True
|
||||
|
||||
SERVE_X_ACCEL_REDIRECT=True
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe'
|
||||
|
||||
|
|
|
|||
|
|
@ -35,28 +35,6 @@ http {
|
|||
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 {
|
||||
proxy_pass http://backend/docs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,4 @@
|
|||
"target": "127.0.0.2."
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ services:
|
|||
- ../deploy/dev/instance_a/a.env:/code/.env
|
||||
- ../deploy/dev/instance_a/testdata.py:/code/testdata.py
|
||||
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
|
||||
- ../deploy/dev/instance_a/userfiles:/code/userfiles
|
||||
expose:
|
||||
- 8000
|
||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||
|
|
@ -24,7 +23,6 @@ services:
|
|||
- ../deploy/dev/instance_b/b.env:/code/.env
|
||||
- ../deploy/dev/instance_b/testdata.py:/code/testdata.py
|
||||
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
|
||||
- ../deploy/dev/instance_b/userfiles:/code/userfiles
|
||||
expose:
|
||||
- 8000
|
||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||
|
|
@ -59,7 +57,6 @@ services:
|
|||
- ./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/domains.json:/var/www/domains.json:ro
|
||||
- ./dev/instance_a/userfiles:/var/www/userfiles:ro
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.3:5353:5353"
|
||||
|
|
@ -70,7 +67,6 @@ services:
|
|||
dockerfile: dev/Dockerfile.proxy
|
||||
volumes:
|
||||
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./dev/instance_b/userfiles:/var/www/userfiles:ro
|
||||
ports:
|
||||
- "127.0.0.2:8080:8080"
|
||||
|
||||
|
|
@ -82,7 +78,3 @@ services:
|
|||
- ./dev/zone.json:/dns/zone.json
|
||||
expose:
|
||||
- 8053
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- toolshed-dns
|
||||
|
|
|
|||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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,12 +97,6 @@ Start the fullstack application:
|
|||
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.
|
||||
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.
|
||||
|
|
|
|||
20
frontend/.local/RootCA.crt
Normal file
20
frontend/.local/RootCA.crt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-----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-----
|
||||
28
frontend/.local/RootCA.key
Normal file
28
frontend/.local/RootCA.key
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
-----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-----
|
||||
20
frontend/.local/RootCA.pem
Normal file
20
frontend/.local/RootCA.pem
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-----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
frontend/.local/RootCA.srl
Normal file
1
frontend/.local/RootCA.srl
Normal file
|
|
@ -0,0 +1 @@
|
|||
0C2D2D6E0BCB8B8AFD205E5E4A616C11D96DD566
|
||||
8
frontend/.local/domains.ext
Normal file
8
frontend/.local/domains.ext
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
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
|
||||
42
frontend/.local/fullchain.pem
Normal file
42
frontend/.local/fullchain.pem
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
-----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-----
|
||||
22
frontend/.local/localhost.crt
Normal file
22
frontend/.local/localhost.crt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-----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-----
|
||||
17
frontend/.local/localhost.csr
Normal file
17
frontend/.local/localhost.csr
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-----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-----
|
||||
28
frontend/.local/localhost.key
Normal file
28
frontend/.local/localhost.key
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
-----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-----
|
||||
8
frontend/.local/make_ca.sh
Executable file
8
frontend/.local/make_ca.sh
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/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
|
||||
11
frontend/.local/make_localhost.sh
Executable file
11
frontend/.local/make_localhost.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/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
|
||||
28
frontend/.local/privkey.pem
Normal file
28
frontend/.local/privkey.pem
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
-----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 21bf28a8b6293872f5f7a42c9ca0bc12ede5aaae
|
||||
Subproject commit 98d7936a50a148e0c2c718b3d7b8662ffcb3fde8
|
||||
87
frontend/fullchain.pem
Normal file
87
frontend/fullchain.pem
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
-----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-----
|
||||
14
frontend/nginx.conf
Normal file
14
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://django:8000;
|
||||
}
|
||||
|
||||
}
|
||||
0
frontend/src/components/AuthenticatedAvatar.vue → frontend/node_modules/.forgit
generated
vendored
0
frontend/src/components/AuthenticatedAvatar.vue → frontend/node_modules/.forgit
generated
vendored
2411
frontend/package-lock.json
generated
2411
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,10 +16,7 @@
|
|||
"vue": "^3.2.47",
|
||||
"vue-multiselect": "^2.1.7",
|
||||
"vue-router": "^4.1.6",
|
||||
"vuex": "^4.1.0",
|
||||
"extras": "file:./extras",
|
||||
"jquery": "^3.6.0",
|
||||
"popper.js": "^1.16.1"
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
|
|
|
|||
5
frontend/privkey.pem
Normal file
5
frontend/privkey.pem
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgD6EmCAUWob1zUw4Q
|
||||
F+Pf9cSOmSCTODe6u+Gst177IoihRANCAARYeMpTk1DaC8cigL3DivanGrLQahYB
|
||||
EDm5B26VaS3gUmq9T0RNkEUxJIPnZBwdF8p7xAEBhlTXwgy3eBLAp8lA
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -3,13 +3,23 @@
|
|||
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
<!-- TODO UI für Freunde liste, add, remove -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
import {mapMutations} from 'vuex';
|
||||
import store from '@/store';
|
||||
|
||||
export default {
|
||||
name: 'App'
|
||||
name: 'App',
|
||||
methods: {
|
||||
...mapMutations(['init']),
|
||||
},
|
||||
beforeCreate () {
|
||||
store.commit('load_local')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
BIN
frontend/src/assets/fonts/Inter-Black.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-Black.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Black.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-Black.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-BlackItalic.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-BlackItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-BlackItalic.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Bold.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-Bold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Bold.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-BoldItalic.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-BoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-BoldItalic.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-ExtraBold.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-ExtraBold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-ExtraBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-ExtraBoldItalic.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-ExtraBoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-ExtraBoldItalic.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-ExtraLight-BETA.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-ExtraLight-BETA.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-ExtraLight-BETA.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-ExtraLight-BETA.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-ExtraLightItalic-BETA.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-ExtraLightItalic-BETA.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-ExtraLightItalic-BETA.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-ExtraLightItalic-BETA.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Italic.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-Italic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Italic.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-Italic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Light-BETA.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-Light-BETA.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Light-BETA.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-Light-BETA.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-LightItalic-BETA.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-LightItalic-BETA.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-LightItalic-BETA.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-LightItalic-BETA.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Medium.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-Medium.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Medium.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-MediumItalic.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-MediumItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-MediumItalic.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Regular.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-Regular.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-Regular.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-SemiBold.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-SemiBold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-SemiBold.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-SemiBoldItalic.woff
Normal file
BIN
frontend/src/assets/fonts/Inter-SemiBoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/Inter-SemiBoldItalic.woff2
Normal file
BIN
frontend/src/assets/fonts/Inter-SemiBoldItalic.woff2
Normal file
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