Compare commits

..

16 commits

Author SHA1 Message Date
9acf5a97e2 add explicit non-interactive option to configure.py 2024-10-12 16:02:41 +02:00
8d64a3c528 add vuex store and federation layer for api calls 2024-04-13 01:03:23 +02:00
0fd49bc023 add javascript dns resolver using DoH
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-08 20:54:29 +02:00
6b5ccc2be6 restructure docs
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 19:55:47 +02:00
bbc2faeb7b update README.md 2024-04-06 19:55:47 +02:00
48b9e595ff frontend: add collapsable sidebar
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 18:54:54 +02:00
f8dacef309 development setup using docker 2024-04-06 18:48:53 +02:00
c4c49931a4 frontend: add /login and /register forms 2024-04-06 18:47:41 +02:00
bea56f101a add frontend skeleton 2024-04-06 18:42:39 +02:00
8cf0897ec5 make dataset parsing more robust
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-16 19:58:22 +01:00
f2db5d9dad fix inconsistencies between policy api ant combined info api
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-07 00:44:58 +01:00
b3bae6f5ad prevent duplicate friend requests
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-24 00:36:04 +01:00
52dbe93d3c rename availability policies
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-23 18:51:52 +01:00
f4894d3a8c hide private items from friends in search
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-20 16:21:12 +01:00
93335b2776 add StorageLocation model
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-20 15:26:00 +01:00
0d394d531b add Friendrequests 2023-06-22 11:44:28 +02:00
163 changed files with 2803 additions and 59448 deletions

View file

@ -13,4 +13,5 @@ steps:
- apk add --no-cache gcc musl-dev python3-dev
- pip install --upgrade pip && pip install -r requirements.txt
- python3 configure.py
- coverage run --parallel-mode --concurrency=multiprocessing manage.py test --parallel=$(nproc) && coverage report
- coverage run manage.py test
- coverage report

4
.gitignore vendored
View file

@ -130,5 +130,5 @@ dmypy.json
staticfiles/
userfiles/
backend/templates/
backend/testdata.py
testdata.py
*.sqlite3

View file

@ -1,6 +1,6 @@
# toolshed
## Installation / Development
## Development
``` bash
git clone https://github.com/gr4yj3d1/toolshed.git
@ -12,7 +12,10 @@ or
git clone https://git.neulandlabor.de/j3d1/toolshed.git
```
### Backend
all following development mode commands support auto-reloading and hot-reloading where applicable, they do not need to bw
restarted after changes.
### Backend only
``` bash
cd toolshed/backend
@ -26,7 +29,7 @@ python manage.py runserver 0.0.0.0:8000 --insecure
to run this in properly in production, you need to configure a webserver to serve the static files and proxy the
requests to the backend, then run the backend with just `python manage.py runserver` without the `--insecure` flag.
### Frontend
### Frontend only
``` bash
cd toolshed/frontend
@ -34,13 +37,45 @@ npm install
npm run dev
```
### Docs
### Docs only
``` bash
cd toolshed/docs
mkdocs serve
```
### Full stack
``` bash
cd toolshed
docker-compose -f deploy/docker-compose.override.yml up --build
```
## Deployment
### Requirements
- python3
- python3-pip
- python3-venv
- wget
- unzip
- nginx
- uwsgi
### Installation
* Get the latest release from
`https://git.neulandlabor.de/j3d1/toolshed/releases/download/<version>/toolshed.zip` or
`https://github.com/gr4yj3d1/toolshed/archive/refs/tags/<version>.zip`.
* Unpack it to `/var/www` or wherever you want to install toolshed.
* Create a virtual environment and install the requirements.
* Then run the configuration script.
* Configure your webserver to serve the static files and proxy the requests to the backend.
* Configure your webserver to run the backend with uwsgi.
for detailed instructions see [docs](/docs/deployment.md).
## CLI Client
### Requirements

View file

@ -6,3 +6,5 @@
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# GitHub Copilot persisted chat sessions
/copilot/chatSessions

View file

@ -60,6 +60,8 @@ def verify_incoming_friend_request(request, raw_request_body):
befriender_key = request.data['befriender_key']
except KeyError:
return False
if not befriender or not befriender_key:
return False
if username + "@" + domain != befriender:
return False
if len(befriender_key) != 64:

View file

@ -8,9 +8,18 @@ import dotenv
from django.db import transaction, IntegrityError
def yesno(prompt, default=False):
if not sys.stdin.isatty():
class CmdCtx:
def __init__(self, args):
self.args = args
def yesno(self, prompt, default=False):
if not sys.stdin.isatty() or self.args.noninteractive:
return default
elif self.args.yes:
return True
elif self.args.no:
return False
yes = {'yes', 'y', 'ye'}
no = {'no', 'n'}
@ -31,9 +40,9 @@ def yesno(prompt, default=False):
print('Please respond with "yes" or "no"')
def configure():
def configure(ctx):
if not os.path.exists('.env'):
if not yesno("the .env file does not exist, do you want to create it?", default=True):
if not ctx.yesno("the .env file does not exist, do you want to create it?", default=True):
print('Aborting')
exit(0)
if not os.path.exists('.env.dist'):
@ -56,7 +65,7 @@ def configure():
current_hosts = os.getenv('ALLOWED_HOSTS')
print('Current ALLOWED_HOSTS: {}'.format(current_hosts))
if yesno("Do you want to add ALLOWED_HOSTS?"):
if ctx.yesno("Do you want to add ALLOWED_HOSTS?"):
hosts = input("Enter a comma-separated list of allowed hosts: ")
joined_hosts = current_hosts + ',' + hosts if current_hosts else hosts
dotenv.set_key('.env', 'ALLOWED_HOSTS', joined_hosts)
@ -67,26 +76,29 @@ def configure():
django.setup()
if not os.path.exists('db.sqlite3'):
if not yesno("No database found, do you want to create one?", default=True):
if not ctx.yesno("No database found, do you want to create one?", default=True):
print('Aborting')
exit(0)
from django.core.management import call_command
call_command('migrate')
if yesno("Do you want to create a superuser?"):
if ctx.yesno("Do you want to create a superuser?"):
from django.core.management import call_command
call_command('createsuperuser')
call_command('collectstatic', '--no-input')
if yesno("Do you want to import all categories, properties and tags contained in this repository?", default=True):
if ctx.yesno("Do you want to import all categories, properties and tags contained in this repository?",
default=True):
from hostadmin.serializers import CategorySerializer, PropertySerializer, TagSerializer
from hostadmin.models import ImportedIdentifierSets
from hashlib import sha256
if not os.path.exists('shared_data'):
os.mkdir('shared_data')
files = os.listdir('shared_data')
idsets = {}
hashes = {}
for file in files:
if file.endswith('.json'):
name = "git:" + file[:-5]
@ -94,6 +106,8 @@ def configure():
try:
idset = json.load(f)
idsets[name] = idset
f.seek(0)
hashes[name] = sha256(f.read().encode()).hexdigest()
except json.decoder.JSONDecodeError:
print('Error: invalid JSON in file {}'.format(file))
imported_sets = ImportedIdentifierSets.objects.all()
@ -108,9 +122,13 @@ def configure():
unmet_deps = [dep for dep in idset['depends'] if not imported_sets.filter(name=dep).exists()]
if unmet_deps:
if all([dep in idsets.keys() for dep in unmet_deps]):
if all([dep in queue for dep in unmet_deps]):
print('Not all dependencies for {} are imported, postponing'.format(name))
queue.append(name)
continue
else:
print('Error: unresolvable dependencies for {}: {}'.format(name, unmet_deps))
continue
else:
print('unknown dependencies for {}: {}'.format(name, unmet_deps))
continue
@ -131,10 +149,15 @@ def configure():
serializer = TagSerializer(data=tag)
if serializer.is_valid():
serializer.save(origin=name)
imported_sets.create(name=name)
imported_sets.create(name=name, hash=hashes[name])
except IntegrityError:
print('Error: integrity error while importing {}\n\tmight be cause by name conflicts with existing'
' categories, properties or tags'.format(name))
transaction.set_rollback(True)
continue
except Exception as e:
print('Error: {}'.format(e))
transaction.set_rollback(True)
continue
@ -183,6 +206,7 @@ def main():
parser = ArgumentParser(description='Toolshed Server Configuration')
parser.add_argument('--yes', '-y', help='Answer yes to all questions', action='store_true')
parser.add_argument('--no', '-n', help='Answer no to all questions', action='store_true')
parser.add_argument('--noninteractive', '-x', help="Run in noninteractive mode", action='store_true')
parser.add_argument('cmd', help='Command', default='configure', nargs='?')
args = parser.parse_args()
@ -190,12 +214,16 @@ def main():
print('Error: --yes and --no are mutually exclusive')
exit(1)
ctx = CmdCtx(args)
if args.cmd == 'configure':
configure()
configure(ctx)
elif args.cmd == 'reset':
reset()
elif args.cmd == 'testdata':
testdata()
elif args.cmd == 'migrate':
print('not implemented yet')
else:
print('Unknown command: {}'.format(args.cmd))
exit(1)

View file

@ -1,6 +1,6 @@
from django.contrib import admin
from .models import Domain
from .models import Domain, ImportedIdentifierSets
class DomainAdmin(admin.ModelAdmin):
@ -9,3 +9,11 @@ class DomainAdmin(admin.ModelAdmin):
admin.site.register(Domain, DomainAdmin)
class ImportedIdentifierSetsAdmin(admin.ModelAdmin):
list_display = ('name', 'hash', 'created_at')
list_filter = ('name', 'hash', 'created_at')
admin.site.register(ImportedIdentifierSets, ImportedIdentifierSetsAdmin)

View file

@ -0,0 +1,39 @@
# Generated by Django 4.2.2 on 2024-03-11 15:19
import os
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hostadmin', '0002_importedidentifiersets'),
]
def calculate_hash(apps, schema_editor):
from hostadmin.models import ImportedIdentifierSets
for identifier_set in ImportedIdentifierSets.objects.all():
if not identifier_set.hash:
print("update", identifier_set.name)
filename = "shared_data/" + identifier_set.name.strip('git:') + ".json"
if not os.path.exists(filename):
continue
from hashlib import sha256
with open(filename, 'r') as file:
data = file.read()
identifier_set.hash = sha256(data.encode()).hexdigest()
identifier_set.save()
operations = [
migrations.AddField(
model_name='importedidentifiersets',
name='hash',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RunPython(calculate_hash),
migrations.AlterField(
model_name='importedidentifiersets',
name='hash',
field=models.CharField(max_length=255, unique=True),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 4.2.2 on 2024-03-14 16:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('hostadmin', '0003_importedidentifiersets_hash'),
]
operations = [
migrations.AlterModelOptions(
name='importedidentifiersets',
options={'verbose_name_plural': 'imported identifier sets'},
),
]

View file

@ -12,4 +12,8 @@ class Domain(models.Model):
class ImportedIdentifierSets(models.Model):
name = models.CharField(max_length=255, unique=True)
hash = models.CharField(max_length=255, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name_plural = 'imported identifier sets'

View file

@ -5,6 +5,32 @@ from hostadmin.models import Domain
from toolshed.models import Category, Property, Tag
class SlugPathField(serializers.SlugRelatedField):
def to_internal_value(self, data):
path = data.split('/') if '/' in data else [data]
candidates = self.get_queryset().filter(name=path[-1])
if len(candidates) == 1:
return candidates.first()
if len(candidates) == 0:
raise serializers.ValidationError(
"No {} with name '{}' found".format(self.queryset.model.__name__, path[-1]))
if len(candidates) > 1 and len(path) == 1:
raise serializers.ValidationError("Multiple {}s with name '{}' found, please specify the parent".format(
self.queryset.model.__name__, path[-1]))
parent = self.to_internal_value('/'.join(path[:-1]))
candidates = self.get_queryset().filter(name=path[-1], parent=parent)
if len(candidates) == 1:
return candidates.first()
if len(candidates) == 0:
raise serializers.ValidationError(
"No {} with name '{}' found".format(self.queryset.model.__name__, path[-1]))
def to_representation(self, value):
source = getattr(value, self.field_name, None) # should this use self.source?
prefix = self.to_representation(source) + '/' if source else ''
return prefix + getattr(value, self.slug_field)
class DomainSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
@ -12,12 +38,21 @@ class DomainSerializer(serializers.ModelSerializer):
model = Domain
fields = ['name', 'owner', 'open_registration']
def create(self, validated_data):
return super().create(validated_data)
class CategorySerializer(serializers.ModelSerializer):
parent = serializers.SlugRelatedField(slug_field='name', queryset=Category.objects.all(), required=False)
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
def validate(self, attrs):
if 'name' in attrs:
if '/' in attrs['name']:
raise serializers.ValidationError("Category name cannot contain '/'")
return attrs
def create(self, validated_data):
try:
return Category.objects.create(**validated_data)
except Exception as e:
raise serializers.ValidationError(e)
class Meta:
model = Category
@ -27,7 +62,19 @@ class CategorySerializer(serializers.ModelSerializer):
class PropertySerializer(serializers.ModelSerializer):
category = serializers.SlugRelatedField(slug_field='name', queryset=Category.objects.all(), required=False)
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
def validate(self, attrs):
if 'name' in attrs:
if '/' in attrs['name']:
raise serializers.ValidationError("Property name cannot contain '/'")
return attrs
def create(self, validated_data):
try:
return Property.objects.create(**validated_data)
except Exception as e:
raise serializers.ValidationError(e)
class Meta:
model = Property
@ -38,7 +85,19 @@ class PropertySerializer(serializers.ModelSerializer):
class TagSerializer(serializers.ModelSerializer):
category = serializers.SlugRelatedField(slug_field='name', queryset=Category.objects.all(), required=False)
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
def validate(self, attrs):
if 'name' in attrs:
if '/' in attrs['name']:
raise serializers.ValidationError("Tag name cannot contain '/'")
return attrs
def create(self, validated_data):
try:
return Tag.objects.create(**validated_data)
except Exception as e:
raise serializers.ValidationError(e)
class Meta:
model = Tag

View file

@ -100,7 +100,8 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
response = client.get('/api/categories/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(),
["cat1", "cat2", "cat3", "cat1/subcat1", "cat1/subcat2", "cat1/subcat1/subcat3"])
["cat1", "cat2", "cat3", "cat1/subcat1",
"cat1/subcat2", "cat1/subcat1/subcat1", "cat1/subcat1/subcat2"])
def test_admin_get_categories_fail(self):
response = client.get('/admin/categories/', self.f['local_user1'])
@ -109,7 +110,7 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
def test_admin_get_categories(self):
response = client.get('/admin/categories/', self.f['admin'])
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 6)
self.assertEqual(len(response.json()), 7)
self.assertEqual(response.json()[0]['name'], 'cat1')
self.assertEqual(response.json()[1]['name'], 'cat2')
self.assertEqual(response.json()[2]['name'], 'cat3')
@ -117,10 +118,12 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
self.assertEqual(response.json()[3]['parent'], 'cat1')
self.assertEqual(response.json()[4]['name'], 'subcat2')
self.assertEqual(response.json()[4]['parent'], 'cat1')
self.assertEqual(response.json()[5]['name'], 'subcat3')
self.assertEqual(response.json()[5]['parent'], 'subcat1')
self.assertEqual(response.json()[5]['name'], 'subcat1')
self.assertEqual(response.json()[5]['parent'], 'cat1/subcat1')
self.assertEqual(response.json()[6]['name'], 'subcat2')
self.assertEqual(response.json()[6]['parent'], 'cat1/subcat1')
def test_admin_create_category(self):
def test_admin_post_category(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'cat4'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['name'], 'cat4')
@ -128,6 +131,40 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
self.assertEqual(response.json()['parent'], None)
self.assertEqual(response.json()['origin'], 'api')
def test_admin_post_category_duplicate(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'cat3'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_invalid(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'cat/4'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_parent_not_found(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat4'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_parent_ambiguous(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'subcat1'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_parent_subcategory(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat1/subcat1'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['name'], 'subcat4')
self.assertEqual(response.json()['description'], None)
self.assertEqual(response.json()['parent'], 'cat1/subcat1')
self.assertEqual(response.json()['origin'], 'api')
def test_admin_post_category_parent_subcategory_not_found(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat2/subcat1'})
self.assertEqual(response.status_code, 400)
def test_admin_post_category_parent_subcategory_ambiguous(self):
from toolshed.models import Category
self.f['subcat111'] = Category.objects.create(name='subcat1', parent=self.f['subcat11'], origin='test')
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'subcat1/subcat1'})
self.assertEqual(response.status_code, 400)
def test_admin_post_subcategory(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat4', 'parent': 'cat1'})
self.assertEqual(response.status_code, 201)
@ -136,6 +173,18 @@ class CategoryApiTestCase(UserTestMixin, CategoryTestMixin, ToolshedTestCase):
self.assertEqual(response.json()['parent'], 'cat1')
self.assertEqual(response.json()['origin'], 'api')
def test_admin_post_subcategory_duplicate(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat2', 'parent': 'cat1'})
self.assertEqual(response.status_code, 400)
def test_admin_post_subcategory_distinct_duplicate(self):
response = client.post('/admin/categories/', self.f['admin'], {'name': 'subcat2', 'parent': 'cat2'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['name'], 'subcat2')
self.assertEqual(response.json()['description'], None)
self.assertEqual(response.json()['parent'], 'cat2')
self.assertEqual(response.json()['origin'], 'api')
def test_admin_put_category(self):
response = client.put('/admin/categories/1/', self.f['admin'], {'name': 'cat5'})
self.assertEqual(response.status_code, 200)
@ -188,6 +237,14 @@ class TagApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, ToolshedTes
self.assertEqual(response.json()['origin'], 'api')
self.assertEqual(response.json()['category'], None)
def test_admin_create_tag_duplicate(self):
response = client.post('/admin/tags/', self.f['admin'], {'name': 'tag3'})
self.assertEqual(response.status_code, 400)
def test_admin_create_tag_invalid(self):
response = client.post('/admin/tags/', self.f['admin'], {'name': 'tag/4'})
self.assertEqual(response.status_code, 400)
def test_admin_put_tag(self):
response = client.put('/admin/tags/1/', self.f['admin'], {'name': 'tag5'})
self.assertEqual(response.status_code, 200)
@ -250,7 +307,13 @@ class PropertyApiTestCase(UserTestMixin, CategoryTestMixin, PropertyTestMixin, T
self.assertEqual(response.json()['base2_prefix'], False)
self.assertEqual(response.json()['dimensions'], 1)
# self.assertEqual(response.json()['sort_lexicographically'], False)
def test_admin_create_property_duplicate(self):
response = client.post('/admin/properties/', self.f['admin'], {'name': 'prop3', 'category': 'cat1'})
self.assertEqual(response.status_code, 400)
def test_admin_create_property_invalid(self):
response = client.post('/admin/properties/', self.f['admin'], {'name': 'prop/4'})
self.assertEqual(response.status_code, 400)
def test_admin_put_property(self):
response = client.put('/admin/properties/1/', self.f['admin'], {'name': 'prop5'})
@ -265,8 +328,6 @@ class PropertyApiTestCase(UserTestMixin, CategoryTestMixin, PropertyTestMixin, T
self.assertEqual(response.json()['base2_prefix'], False)
self.assertEqual(response.json()['dimensions'], 1)
# self.assertEqual(response.json()['sort_lexicographically'], False)
def test_admin_patch_property(self):
response = client.patch('/admin/properties/1/', self.f['admin'], {'name': 'prop5'})
self.assertEqual(response.status_code, 200)

View file

@ -1,6 +1,6 @@
from django.contrib import admin
from toolshed.models import InventoryItem, Property, Tag, ItemProperty, ItemTag
from toolshed.models import InventoryItem, Property, Tag, Category
class InventoryItemAdmin(admin.ModelAdmin):
@ -12,16 +12,24 @@ admin.site.register(InventoryItem, InventoryItemAdmin)
class PropertyAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
search_fields = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
admin.site.register(Property, PropertyAdmin)
class TagAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ('name', 'description', 'category', 'origin')
search_fields = ('name', 'description', 'category', 'origin')
admin.site.register(Tag, TagAdmin)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'parent', 'origin')
search_fields = ('name', 'description', 'parent', 'origin')
admin.site.register(Category, CategoryAdmin)

View file

@ -64,15 +64,23 @@ class FriendsRequests(APIView, ViewSetMixin):
befriendee_username, befriendee_domain = split_userhandle_or_throw(request.data['befriendee'])
if befriender_domain == befriendee_domain and befriender_username == befriendee_username:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'cannot befriend yourself'})
if user := authenticate_request_against_local_users(request, raw_request):
if user := authenticate_request_against_local_users(request, raw_request): # befriender is local
secret = secrets.token_hex(64)
befriendee_user = ToolshedUser.objects.filter(username=befriendee_username, domain=befriendee_domain)
if befriendee_user.exists():
if befriendee_user.exists(): # befriendee is local (both are local)
if user.friends.filter(username=befriendee_username, domain=befriendee_domain).exists():
return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': "exists"})
existing_request = FriendRequestIncoming.objects.filter(
befriender_username=befriender_username,
befriender_domain=befriender_domain,
befriendee_user=befriendee_user.get())
if existing_request.exists():
return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': "exists"})
FriendRequestIncoming.objects.create(
befriender_username=befriender_username,
befriender_domain=befriender_domain,
befriender_public_key=user.public_identity.public_key,
secret=secret, # request.data['secret'] # TODO ??
secret=secret,
befriendee_user=befriendee_user.get(),
)
return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': "pending"})
@ -81,7 +89,7 @@ class FriendsRequests(APIView, ViewSetMixin):
befriender_user=user,
befriendee_username=befriendee_username,
befriendee_domain=befriendee_domain,
secret=secret, # request.data['secret'] # TODO ??
secret=secret,
)
return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': "pending"})
elif verify_incoming_friend_request(request, raw_request):

View file

@ -5,7 +5,7 @@ from rest_framework.response import Response
from hostadmin.models import Domain
from authentication.signature_auth import SignatureAuthentication
from toolshed.models import Tag, Property, Category
from toolshed.models import Tag, Property, Category, InventoryItem
from toolshed.serializers import CategorySerializer, PropertySerializer
from backend.settings import TOOLSHED_VERSION
@ -51,8 +51,7 @@ def list_categories(request, format=None): # /categories/
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def list_availability_policies(request, format=None): # /availability_policies/
policies = ['private', 'friends', 'internal', 'public']
return Response(policies)
return Response(InventoryItem.AVAILABILITY_POLICY_CHOICES)
@api_view(['GET'])
@ -62,9 +61,11 @@ def combined_info(request, format=None): # /info/
tags = [tag.name for tag in Tag.objects.all()]
properties = PropertySerializer(Property.objects.all(), many=True).data
categories = [str(category) for category in Category.objects.all()]
policies = ['private', 'friends', 'internal', 'public']
policies = InventoryItem.AVAILABILITY_POLICY_CHOICES
domains = [domain.name for domain in Domain.objects.filter(open_registration=True)]
return Response({'tags': tags, 'properties': properties, 'availability_policies': policies, 'categories': categories, 'domains': domains})
return Response(
{'tags': tags, 'properties': properties, 'availability_policies': policies, 'categories': categories,
'domains': domains})
urlpatterns = [

View file

@ -7,8 +7,8 @@ from rest_framework.response import Response
from authentication.models import ToolshedUser, KnownIdentity
from authentication.signature_auth import SignatureAuthentication
from toolshed.models import InventoryItem
from toolshed.serializers import InventoryItemSerializer
from toolshed.models import InventoryItem, StorageLocation
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer
router = routers.SimpleRouter()
@ -24,6 +24,7 @@ def inventory_items(identity):
for friend in identity.friends.all():
if friend_user := friend.user.get():
for item in friend_user.inventory_items.all():
if item.availability_policy != 'private':
yield item
@ -61,7 +62,19 @@ def search_inventory_items(request):
return Response({'error': 'No query provided.'}, status=400)
class StorageLocationViewSet(viewsets.ModelViewSet):
serializer_class = StorageLocationSerializer
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
return StorageLocation.objects.filter(owner=self.request.user.user.get())
return StorageLocation.objects.none()
router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations')
urlpatterns = router.urls + [
path('search/', search_inventory_items, name='search_inventory_items'),

View file

@ -0,0 +1,32 @@
# Generated by Django 4.2.2 on 2024-02-20 13:50
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', '0003_inventoryitem_files_and_more'),
]
operations = [
migrations.CreateModel(
name='StorageLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True, null=True)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='storage_locations', to='toolshed.category')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='storage_locations', to=settings.AUTH_USER_MODEL)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='toolshed.storagelocation')),
],
),
migrations.AddField(
model_name='inventoryitem',
name='storage_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='toolshed.storagelocation'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2024-02-20 15:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0004_storagelocation_inventoryitem_storage_location'),
]
operations = [
migrations.AlterField(
model_name='inventoryitem',
name='availability_policy',
field=models.CharField(choices=[('sell', 'Sell'), ('rent', 'Rent'), ('lend', 'Lend'), ('share', 'Share'), ('private', 'Private')], default='private', max_length=20),
),
]

View file

@ -0,0 +1,67 @@
# Generated by Django 4.2.2 on 2024-03-14 16:54
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0005_alter_inventoryitem_availability_policy'),
]
operations = [
migrations.AlterModelOptions(
name='tag',
options={'verbose_name_plural': 'tags'},
),
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='category',
name='parent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='toolshed.category'),
),
migrations.AlterField(
model_name='inventoryitem',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='toolshed.category'),
),
migrations.AlterField(
model_name='property',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='properties', to='toolshed.category'),
),
migrations.AlterField(
model_name='tag',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='toolshed.category'),
),
migrations.AddConstraint(
model_name='category',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', False)), fields=('name', 'parent'), name='category_unique_name_parent'),
),
migrations.AddConstraint(
model_name='category',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='category_unique_name_no_parent'),
),
migrations.AddConstraint(
model_name='property',
constraint=models.UniqueConstraint(condition=models.Q(('category__isnull', False)), fields=('name', 'category'), name='property_unique_name_category'),
),
migrations.AddConstraint(
model_name='property',
constraint=models.UniqueConstraint(condition=models.Q(('category__isnull', True)), fields=('name',), name='property_unique_name_no_category'),
),
migrations.AddConstraint(
model_name='tag',
constraint=models.UniqueConstraint(condition=models.Q(('category__isnull', False)), fields=('name', 'category'), name='tag_unique_name_category'),
),
migrations.AddConstraint(
model_name='tag',
constraint=models.UniqueConstraint(condition=models.Q(('category__isnull', True)), fields=('name',), name='tag_unique_name_no_category'),
),
]

View file

@ -8,13 +8,19 @@ from files.models import File
class Category(SoftDeleteModel):
name = models.CharField(max_length=255, unique=True)
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, related_name='children')
origin = models.CharField(max_length=255, null=False, blank=False)
class Meta:
verbose_name_plural = 'categories'
constraints = [
models.UniqueConstraint(fields=['name', 'parent'], condition=models.Q(parent__isnull=False),
name='category_unique_name_parent'),
models.UniqueConstraint(fields=['name'], condition=models.Q(parent__isnull=True),
name='category_unique_name_no_parent')
]
def __str__(self):
parent = str(self.parent) + "/" if self.parent else ""
@ -24,7 +30,7 @@ class Category(SoftDeleteModel):
class Property(models.Model):
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, related_name='properties')
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='properties')
unit_symbol = models.CharField(max_length=16, null=True, blank=True)
unit_name = models.CharField(max_length=255, null=True, blank=True)
unit_name_plural = models.CharField(max_length=255, null=True, blank=True)
@ -34,6 +40,12 @@ class Property(models.Model):
class Meta:
verbose_name_plural = 'properties'
constraints = [
models.UniqueConstraint(fields=['name', 'category'], condition=models.Q(category__isnull=False),
name='property_unique_name_category'),
models.UniqueConstraint(fields=['name'], condition=models.Q(category__isnull=True),
name='property_unique_name_no_category')
]
def __str__(self):
return self.name
@ -42,26 +54,44 @@ class Property(models.Model):
class Tag(models.Model):
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, related_name='tags')
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='tags')
origin = models.CharField(max_length=255, null=False, blank=False)
class Meta:
verbose_name_plural = 'tags'
constraints = [
models.UniqueConstraint(fields=['name', 'category'], condition=models.Q(category__isnull=False),
name='tag_unique_name_category'),
models.UniqueConstraint(fields=['name'], condition=models.Q(category__isnull=True),
name='tag_unique_name_no_category')
]
def __str__(self):
return self.name
class InventoryItem(SoftDeleteModel):
AVAILABILITY_POLICY_CHOICES = (
('sell', 'Sell'),
('rent', 'Rent'),
('lend', 'Lend'),
('share', 'Share'),
('private', 'Private'),
)
published = models.BooleanField(default=False)
name = models.CharField(max_length=255, null=True, blank=True)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
related_name='inventory_items')
availability_policy = models.CharField(max_length=255, default="private")
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items')
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items')
created_at = models.DateTimeField(auto_now_add=True)
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.CASCADE, null=True, blank=True,
related_name='inventory_items')
def clean(self):
if (self.name is None or self.name == "") and self.files.count() == 0:
@ -77,3 +107,16 @@ class ItemProperty(models.Model):
class ItemTag(models.Model):
tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
class StorageLocation(models.Model):
name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
related_name='storage_locations')
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations')
def __str__(self):
parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name

View file

@ -3,7 +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
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation
class FriendSerializer(serializers.ModelSerializer):
@ -11,7 +11,7 @@ class FriendSerializer(serializers.ModelSerializer):
class Meta:
model = KnownIdentity
fields = ['username', 'public_key']
fields = ['id', 'username', 'public_key']
def get_username(self, obj):
return obj.username + '@' + obj.domain
@ -48,6 +48,23 @@ class CategorySerializer(serializers.ModelSerializer):
return Category.objects.get(name=data.split("/")[-1])
class StorageLocationSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
category = CategorySerializer(required=False, allow_null=True)
path = serializers.SerializerMethodField()
class Meta:
model = StorageLocation
fields = ['id', 'name', 'description', 'path', 'category', 'owner']
read_only_fields = ['path']
@staticmethod
def get_path(obj):
if obj.parent:
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
return obj.name
class ItemPropertySerializer(serializers.ModelSerializer):
property = PropertySerializer(read_only=True)
@ -74,7 +91,7 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class Meta:
model = InventoryItem
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
'tags', 'properties', 'files']
'tags', 'properties', 'files', 'storage_location']
def to_internal_value(self, data):
files = data.pop('files', [])

View file

@ -1,4 +1,4 @@
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation
class CategoryTestMixin:
@ -8,13 +8,16 @@ class CategoryTestMixin:
self.f['cat3'] = Category.objects.create(name='cat3', origin='test')
self.f['subcat1'] = Category.objects.create(name='subcat1', parent=self.f['cat1'], origin='test')
self.f['subcat2'] = Category.objects.create(name='subcat2', parent=self.f['cat1'], origin='test')
self.f['subcat3'] = Category.objects.create(name='subcat3', parent=self.f['subcat1'], origin='test')
self.f['subcat11'] = Category.objects.create(name='subcat1', parent=self.f['subcat1'], origin='test')
self.f['subcat12'] = Category.objects.create(name='subcat2', parent=self.f['subcat1'], origin='test')
class TagTestMixin:
def prepare_tags(self):
self.f['tag1'] = Tag.objects.create(name='tag1', description='tag1 description', category=self.f['cat1'], origin='test')
self.f['tag2'] = Tag.objects.create(name='tag2', description='tag2 description', category=self.f['cat1'], origin='test')
self.f['tag1'] = Tag.objects.create(name='tag1', description='tag1 description', category=self.f['cat1'],
origin='test')
self.f['tag2'] = Tag.objects.create(name='tag2', description='tag2 description', category=self.f['cat1'],
origin='test')
self.f['tag3'] = Tag.objects.create(name='tag3', origin='test')
@ -41,3 +44,13 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
self.f['item2'].tags.add(self.f['tag2'], through_defaults={})
ItemProperty.objects.create(inventory_item=self.f['item2'], property=self.f['prop1'], value='value1').save()
ItemProperty.objects.create(inventory_item=self.f['item2'], property=self.f['prop2'], value='value2').save()
class LocationTestMixin:
def prepare_locations(self):
self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1'])
self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1'])
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'])

View file

@ -43,7 +43,8 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
def test_policy_api(self):
response = client.get('/api/availability_policies/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), ['private', 'friends', 'internal', 'public'])
self.assertEqual(response.json(), [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'], ['share', 'Share'],
['private', 'Private']])
def test_combined_api_anonymous(self):
response = anonymous_client.get('/api/info/')
@ -52,9 +53,11 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
def test_combined_api(self):
response = client.get('/api/info/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['availability_policies'], ['private', 'friends', 'internal', 'public'])
self.assertEqual(response.json()['availability_policies'], [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'],
['share', 'Share'], ['private', 'Private']])
self.assertEqual(response.json()['categories'],
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat3'])
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat1',
'cat1/subcat1/subcat2'])
self.assertEqual(response.json()['tags'], ['tag1', 'tag2', 'tag3'])
self.assertEqual([p['name'] for p in response.json()['properties']], ['prop1', 'prop2', 'prop3'])
self.assertEqual(response.json()['domains'], ['example.com'])

View file

@ -17,10 +17,11 @@ class CategoryTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
self.assertEqual(self.f['cat1'].children.last(), self.f['subcat2'])
self.assertEqual(self.f['subcat1'].parent, self.f['cat1'])
self.assertEqual(self.f['subcat2'].parent, self.f['cat1'])
self.assertEqual(self.f['subcat1'].children.count(), 1)
self.assertEqual(self.f['subcat1'].children.count(), 2)
self.assertEqual(str(self.f['subcat1']), 'cat1/subcat1')
self.assertEqual(str(self.f['subcat2']), 'cat1/subcat2')
self.assertEqual(str(self.f['subcat3']), 'cat1/subcat1/subcat3')
self.assertEqual(str(self.f['subcat11']), 'cat1/subcat1/subcat1')
self.assertEqual(str(self.f['subcat12']), 'cat1/subcat1/subcat2')
class CategoryApiTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
@ -33,10 +34,12 @@ class CategoryApiTestCase(CategoryTestMixin, UserTestMixin, ToolshedTestCase):
def test_get_categories(self):
reply = client.get('/api/categories/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 6)
self.assertEqual(len(reply.json()), 7)
self.assertEqual(reply.json()[0], 'cat1')
self.assertEqual(reply.json()[1], 'cat2')
self.assertEqual(reply.json()[2], 'cat3')
self.assertEqual(reply.json()[3], 'cat1/subcat1')
self.assertEqual(reply.json()[4], 'cat1/subcat2')
self.assertEqual(reply.json()[5], 'cat1/subcat1/subcat3')
self.assertEqual(reply.json()[5], 'cat1/subcat1/subcat1')
self.assertEqual(reply.json()[6], 'cat1/subcat1/subcat2')

View file

@ -210,6 +210,17 @@ class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase):
})
self.assertEqual(reply.status_code, 400)
def test_post_request_missing_key_none(self):
befriender = self.f['ext_user1']
befriendee = self.f['local_user1']
reply = client.post('/api/friendrequests/', befriender, {
'befriender': str(befriender),
'befriendee': str(befriendee),
'befriender_key': None,
'secret': 'secret2'
})
self.assertEqual(reply.status_code, 400)
def test_post_request_breaking_key(self):
befriender = self.f['ext_user1']
befriendee = self.f['local_user1']
@ -357,3 +368,43 @@ class FriendRequestOutgoingTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(befriendee.friends.count(), 1)
self.assertEqual(befriendee.friends.first().username, befriender.username)
self.assertEqual(befriendee.friends.first().domain, befriender.domain)
class FriendRequestCombinedTestCase(UserTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
def test_friend_request_combined(self):
befriender = self.f['local_user1']
befriendee = self.f['local_user2']
reply1 = client.post('/api/friendrequests/', befriender, {
'befriender': str(befriender),
'befriendee': str(befriendee),
})
secret = reply1.json()['secret']
reply2 = client.post('/api/friendrequests/', befriender, {
'befriender': str(befriender),
'befriender_key': befriender.public_key(),
'befriendee': str(befriendee),
'secret': secret
})
self.assertEqual(reply1.status_code, 201)
self.assertEqual(reply2.status_code, 208)
self.assertEqual(reply1.json()['status'], 'pending')
self.assertEqual(reply2.json()['status'], 'exists')
self.assertEqual(FriendRequestIncoming.objects.count(), 1)
def test_friend_request_already_friends(self):
befriender = self.f['local_user1']
befriendee = self.f['local_user2']
befriender.friends.add(befriendee.public_identity)
reply1 = client.post('/api/friendrequests/', befriender, {
'befriender': str(befriender),
'befriendee': str(befriendee),
})
self.assertEqual(reply1.status_code, 208)
self.assertEqual(reply1.json()['status'], 'exists')
self.assertEqual(FriendRequestIncoming.objects.count(), 0)

View file

@ -38,7 +38,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def test_post_new_item(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'friends',
'availability_policy': 'rent',
'category': 'cat2',
'name': 'test3',
'description': 'test',
@ -50,7 +50,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 201)
self.assertEqual(InventoryItem.objects.count(), 3)
item = InventoryItem.objects.get(name='test3')
self.assertEqual(item.availability_policy, 'friends')
self.assertEqual(item.availability_policy, 'rent')
self.assertEqual(item.category, Category.objects.get(name='cat2'))
self.assertEqual(item.name, 'test3')
self.assertEqual(item.description, 'test')
@ -61,7 +61,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def test_post_new_item2(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'friends',
'availability_policy': 'share',
'name': 'test3',
'description': 'test',
'owned_quantity': 1,
@ -70,7 +70,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 201)
self.assertEqual(InventoryItem.objects.count(), 3)
item = InventoryItem.objects.get(name='test3')
self.assertEqual(item.availability_policy, 'friends')
self.assertEqual(item.availability_policy, 'share')
self.assertEqual(item.category, None)
self.assertEqual(item.name, 'test3')
self.assertEqual(item.description, 'test')
@ -80,7 +80,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def test_post_new_item_empty(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'friends',
'availability_policy': 'rent',
'owned_quantity': 1,
'image': '',
})
@ -89,7 +89,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def test_post_new_item3(self):
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
'availability_policy': 'friends',
'availability_policy': 'private',
'name': 'test3',
'description': 'test',
'owned_quantity': 1,
@ -99,7 +99,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 201)
self.assertEqual(InventoryItem.objects.count(), 3)
item = InventoryItem.objects.get(name='test3')
self.assertEqual(item.availability_policy, 'friends')
self.assertEqual(item.availability_policy, 'private')
self.assertEqual(item.category, None)
self.assertEqual(item.name, 'test3')
self.assertEqual(item.description, 'test')
@ -109,7 +109,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def test_put_item(self):
reply = client.put('/api/inventory_items/1/', self.f['local_user1'], {
'availability_policy': 'friends',
'availability_policy': 'sell',
'name': 'test4',
'description': 'new description',
'owned_quantity': 100,
@ -121,7 +121,7 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 200)
self.assertEqual(InventoryItem.objects.count(), 2)
item = InventoryItem.objects.get(id=1)
self.assertEqual(item.availability_policy, 'friends')
self.assertEqual(item.availability_policy, 'sell')
self.assertEqual(item.category, None)
self.assertEqual(item.name, 'test4')
self.assertEqual(item.description, 'new description')

View file

@ -0,0 +1,71 @@
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin
from toolshed.models import InventoryItem, Category
from toolshed.tests import InventoryTestMixin, LocationTestMixin
client = SignatureAuthClient()
class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_categories()
self.prepare_tags()
self.prepare_properties()
self.prepare_locations()
self.prepare_inventory()
def test_locations(self):
self.assertEqual("loc1", str(self.f['loc1']))
self.assertEqual("loc1", self.f['loc1'].name)
self.assertEqual("loc2", str(self.f['loc2']))
self.assertEqual("loc2", self.f['loc2'].name)
self.assertEqual("loc1/loc3", str(self.f['loc3']))
self.assertEqual("loc3", self.f['loc3'].name)
self.assertEqual(self.f['loc1'], self.f['loc3'].parent)
self.assertEqual("loc1/loc4", str(self.f['loc4']))
self.assertEqual("loc4", self.f['loc4'].name)
self.assertEqual(self.f['loc1'], self.f['loc4'].parent)
def test_get_inventory(self):
reply = client.get('/api/inventory_items/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 2)
self.assertEqual(reply.json()[0]['name'], 'test1')
self.assertEqual(reply.json()[0]['description'], 'test')
self.assertEqual(reply.json()[0]['owned_quantity'], 1)
self.assertEqual(reply.json()[0]['tags'], [])
self.assertEqual(reply.json()[0]['properties'], [])
self.assertEqual(reply.json()[0]['category'], 'cat1')
self.assertEqual(reply.json()[0]['availability_policy'], 'friends')
self.assertEqual(reply.json()[1]['name'], 'test2')
self.assertEqual(reply.json()[1]['description'], 'test2')
self.assertEqual(reply.json()[1]['owned_quantity'], 1)
self.assertEqual(reply.json()[1]['tags'], ['tag1', 'tag2'])
self.assertEqual(reply.json()[1]['properties'],
[{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}])
self.assertEqual(reply.json()[1]['category'], 'cat1')
self.assertEqual(reply.json()[1]['availability_policy'], 'friends')
def test_get_inventory_item(self):
reply = client.get('/api/storage_locations/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 4)
self.assertEqual(reply.json()[0]['name'], 'loc1')
self.assertEqual(reply.json()[0]['description'], None)
self.assertEqual(reply.json()[0]['category'], None)
self.assertEqual(reply.json()[0]['path'], 'loc1')
self.assertEqual(reply.json()[1]['name'], 'loc2')
self.assertEqual(reply.json()[1]['description'], None)
self.assertEqual(reply.json()[1]['category'], 'cat1')
self.assertEqual(reply.json()[1]['path'], 'loc2')
self.assertEqual(reply.json()[2]['name'], 'loc3')
self.assertEqual(reply.json()[2]['description'], None)
self.assertEqual(reply.json()[2]['category'], None)
self.assertEqual(reply.json()[2]['path'], 'loc1/loc3')
self.assertEqual(reply.json()[3]['name'], 'loc4')
self.assertEqual(reply.json()[3]['description'], None)
self.assertEqual(reply.json()[3]['category'], 'cat1')
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4')

View file

@ -0,0 +1,16 @@
# Use an official Python runtime as instance_a parent image
FROM python:3.9
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /code
# Install dependencies
COPY requirements.txt /code/
RUN pip install --no-cache-dir -r requirements.txt
# Run the application
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--insecure"]

16
deploy/dev/Dockerfile.dns Normal file
View file

@ -0,0 +1,16 @@
# Use an official Python runtime as instance_a parent image
FROM python:3.9
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /dns
COPY dns_server.py /dns/
RUN pip install dnslib
# Run the application
CMD ["python", "dns_server.py"]

View file

@ -0,0 +1,13 @@
# Use an official Node.js runtime as instance_a parent image
FROM node:14
# Set work directory
WORKDIR /app
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package.json ./
RUN npm install
CMD [ "npm", "run", "dev", "--", "--host"]

View file

@ -0,0 +1,14 @@
FROM nginx:bookworm
# snakeoil for localhost
RUN apt-get update && \
apt-get install -y openssl && \
openssl genrsa -des3 -passout pass:x -out server.pass.key 2048 && \
openssl rsa -passin pass:x -in server.pass.key -out server.key && \
rm server.pass.key && \
openssl req -new -key server.key -out server.csr \
-subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=localhost" && \
openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt &&\
mv server.crt /etc/nginx/nginx.crt && \
mv server.key /etc/nginx/nginx.key \

View file

@ -0,0 +1,15 @@
# Use an official Python runtime as instance_a parent image
FROM python:3.9
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /wiki
# Install dependencies
RUN pip install --no-cache-dir mkdocs
# Run the application
CMD ["mkdocs", "serve", "--dev-addr=0.0.0.0:8001"]

72
deploy/dev/dns_server.py Normal file
View file

@ -0,0 +1,72 @@
import http.server
import socketserver
import urllib.parse
import dnslib
import base64
try:
def resolve(zone, qname, qtype):
for record in zone:
if record["name"] == qname and record["type"] == qtype and "value" in record:
return record["value"]
class DnsHttpRequestHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
try:
with open("/dns/zone.json", "r") as f:
import json
zone = json.load(f)
url = urllib.parse.urlparse(self.path)
if url.path != "/dns-query":
self.send_response(404)
return
query = urllib.parse.parse_qs(url.query)
if "dns" not in query:
self.send_response(400)
return
query_base64 = query["dns"][0]
padded = query_base64 + "=" * (4 - len(query_base64) % 4)
raw = base64.b64decode(padded)
dns = dnslib.DNSRecord.parse(raw)
response = dnslib.DNSRecord(dnslib.DNSHeader(id=dns.header.id, qr=1, aa=1, ra=1), q=dns.q)
record = resolve(zone, dns.q.qname, dnslib.QTYPE[dns.q.qtype])
if record:
if dns.q.qtype == dnslib.QTYPE.SRV:
print("SRV record")
reply = dnslib.SRV(record["priority"], record["weight"], record["port"], record["target"])
response.add_answer(dnslib.RR(dns.q.qname, dns.q.qtype, rdata=reply))
else:
response.header.rcode = dnslib.RCODE.NXDOMAIN
print(response)
self.send_response(200)
self.send_header("Content-type", "application/dns-message")
self.end_headers()
pack = response.pack()
self.wfile.write(pack)
return
except Exception as e:
print(f"Error: {e}")
self.send_response(500)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"Internal Server Error")
handler_object = DnsHttpRequestHandler
PORT = 8053
my_server = socketserver.TCPServer(("", PORT), handler_object)
# Start the server
print(f"Starting server on port {PORT}")
my_server.serve_forever()
except Exception as e:
print(f"Error: {e}")

View file

@ -0,0 +1,8 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG=True
# 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'
ALLOWED_HOSTS="*"

View file

@ -0,0 +1,3 @@
[
"127.0.0.3:5353"
]

View file

@ -0,0 +1,3 @@
[
"a.localhost"
]

View file

@ -0,0 +1,96 @@
events {}
http {
upstream backend {
server backend-a:8000;
}
upstream frontend {
server frontend:5173;
}
upstream wiki {
server wiki:8001;
}
upstream dns {
server dns:8053;
}
server {
listen 8080 ssl;
server_name localhost;
ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key;
location /api {
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 /auth {
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_pass http://backend/docs;
}
location /static {
proxy_pass http://backend/static;
}
location /wiki {
proxy_pass http://wiki/wiki;
}
location /livereload {
proxy_pass http://wiki/livereload;
}
location /local/ {
alias /var/www/;
try_files $uri.json =404;
add_header Content-Type application/json;
}
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_pass http://frontend;
}
}
# DoH server
server {
listen 5353 ssl;
server_name localhost;
ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key;
location /dns-query {
proxy_pass http://dns;
# allow any origin
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
}
}
}

View file

@ -0,0 +1,7 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG=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'
ALLOWED_HOSTS="*"

View file

@ -0,0 +1,46 @@
events {}
http {
upstream backend {
server backend-b:8000;
}
server {
listen 8080 ssl;
server_name localhost;
ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key;
location /api {
#proxy_set_header X-Forwarded-For "$http_x_forwarded_for, $realip_remote_addr";
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 /auth {
#proxy_set_header X-Forwarded-For "$http_x_forwarded_for, $realip_remote_addr";
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_pass http://backend/docs;
}
location /static {
proxy_pass http://backend/static;
}
}
}

24
deploy/dev/zone.json Normal file
View file

@ -0,0 +1,24 @@
[
{
"name": "_toolshed-server._tcp.a.localhost.",
"type": "SRV",
"ttl": 60,
"value": {
"priority": 0,
"weight": 5,
"port": 8080,
"target": "127.0.0.1."
}
},
{
"name": "_toolshed-server._tcp.b.localhost.",
"type": "SRV",
"ttl": 60,
"value": {
"priority": 0,
"weight": 5,
"port": 8080,
"target": "127.0.0.2."
}
}
]

View file

@ -0,0 +1,78 @@
version: '3.8'
services:
backend-a:
build:
context: ../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
volumes:
- ../backend:/code
- ../deploy/dev/instance_a/a.env:/code/.env
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
expose:
- 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
backend-b:
build:
context: ../backend/
dockerfile: ../deploy/dev/Dockerfile.backend
volumes:
- ../backend:/code
- ../deploy/dev/instance_b/b.env:/code/.env
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
expose:
- 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
frontend:
build:
context: ../frontend/
dockerfile: ../deploy/dev/Dockerfile.frontend
volumes:
- ../frontend:/app:ro
- /app/node_modules
expose:
- 5173
command: npm run dev -- --host
wiki:
build:
context: ../
dockerfile: deploy/dev/Dockerfile.wiki
volumes:
- ../mkdocs.yml:/wiki/mkdocs.yml
- ../docs:/wiki/docs
expose:
- 8001
command: mkdocs serve --dev-addr=0.0.0.0:8001
proxy-a:
build:
context: ./
dockerfile: dev/Dockerfile.proxy
volumes:
- ./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
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.3:5353:5353"
proxy-b:
build:
context: ./
dockerfile: dev/Dockerfile.proxy
volumes:
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
ports:
- "127.0.0.2:8080:8080"
dns:
build:
context: ./dev/
dockerfile: Dockerfile.dns
volumes:
- ./dev/zone.json:/dns/zone.json
expose:
- 8053

View file

@ -1,43 +0,0 @@
version: '3.3'
services:
# db:
# image: postgres
# container_name: docker-django-vue-db
# environment:
# POSTGRES_USER: user
# POSTGRES_PASSWORD: pass
# POSTGRES_DB: db
# restart: unless-stopped
# ports:
# - "5432:5432"
django:
build:
context: ./backend
dockerfile: ./Dockerfile
command: python backend/manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8002:8000"
networks:
- internal
# depends_on:
# - db
vue:
build:
context: ./frontend
dockerfile: ./Dockerfile
command: nginx -g 'daemon off;'
volumes:
- .:/app
ports:
- "8001:80"
networks:
- internal
- external
depends_on:
- django
networks:
external:
internal:

102
docs/deployment.md Normal file
View file

@ -0,0 +1,102 @@
# Deployment
## Native
### Requirements
- python3
- python3-pip
- python3-venv
- wget
- unzip
- nginx
- uwsgi
- certbot
### Installation
Get the latest release:
``` bash
cd /var/www # or wherever you want to install toolshed
wget https://git.neulandlabor.de/j3d1/toolshed/releases/download/<version>/toolshed.zip
```
or from github:
``` bash
cd /var/www # or wherever you want to install toolshed
wget https://github.com/gr4yj3d1/toolshed/archive/refs/tags/<version>.zip -O toolshed.zip
```
Extract and configure the backend:
``` bash
unzip toolshed.zip
cd toolshed/backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python configure.py
```
Configure uWSGI to serve the backend locally:
``` bash
cd /var/www/toolshed/backend
cp toolshed.ini /etc/uwsgi/apps-available/
ln -s /etc/uwsgi/apps-available/toolshed.ini /etc/uwsgi/apps-enabled/
systemctl restart uwsgi
```
Configure nginx to serve the static files and proxy the requests to the backend:
``` bash
cd /var/www/toolshed/backend
cp toolshed.nginx /etc/nginx/sites-available/toolshed
ln -s /etc/nginx/sites-available/toolsheed /etc/nginx/sites-enabled/
systemctl restart nginx
```
Configure certbot to get a certificate for the domain:
``` bash
certbot --nginx -d <domain>
```
### Update
``` bash
cd /var/www
wget https://git.neulandlabor.de/j3d1/toolshed/releases/download/<version>/toolshed.zip
unzip toolshed.zip
cd toolshed/backend
source venv/bin/activate
pip install -r requirements.txt
python configure.py
systemctl restart uwsgi
```
## Docker
### Requirements
- docker
- docker-compose
- git
### Installation
``` bash
git clone https://git.neulandlabor.de/j3d1/toolshed.git
# or
git clone https://github.com/gr4yj3d1/toolshed.git
cd toolshed
docker-compose -f deploy/docker-compose.prod.yml up -d --build
```
### Update
``` bash
toolshed
git pull
docker-compose -f deploy/docker-compose.prod.yml up -d --build
```

105
docs/development.md Normal file
View file

@ -0,0 +1,105 @@
# Development
``` bash
git clone https://github.com/gr4yj3d1/toolshed.git
```
or
``` bash
git clone https://git.neulandlabor.de/j3d1/toolshed.git
```
## Native
To a certain extent, the frontend and backend can be developed independently. The frontend is a Vue.js project and the
backend is a DRF (Django-Rest-Framework) project. If you want to develop the frontend, you can do so without the backend
and vice
versa. However, especially for the frontend, it is recommended to use the backend as well, as the frontend does not have
a lot of 'offline' functionality.
If you want to run the fullstack application, it is recommended to use the [docker-compose](#docker) method.
### Frontend
install `node.js` and `npm`
on Debian* for example: `sudo apt install npm`
``` bash
cd toolshed/frontend
npm install
npm run dev
```
### Backend
Install `python3`, `pip` and `virtualenv`
on Debian* for example: `sudo apt install python3 python3-pip python3-venv`
Prepare backend environment
``` bash
cd toolshed/backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
Run the test suite:
``` bash
python manage.py test
```
optionally with coverage:
``` bash
coverage run manage.py test
coverage report
```
Start the backend in development mode:
``` bash
python manage.py migrate
cp .env.dist .env
echo "DEBUG = True" >> .env
python manage.py runserver 0.0.0.0:8000
```
provides the api docs at `http://localhost:8000/docs/`
### Docs (Wiki)
Install `mkdocs`
on Debian* for example: `sudo apt install mkdocs`
Start the docs server:
``` bash
cd toolshed/docs
mkdocs serve -a 0.0.0.0:8080
```
## Docker
### Fullstack
Install `docker` and `docker-compose`
on Debian* for example: `sudo apt install docker.io docker-compose`
Start the fullstack application:
``` bash
docker-compose -f deploy/docker-compose.override.yml up --build
```
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.
The frontend is configured to act as if it was served from the domain `a.localhost`.
Access the frontend at `http://localhost:8080/`, backend at `http://localhost:8080/api/`, api docs
at `http://localhost:8080/docs/` and the wiki at `http://localhost:8080/wiki/`.

23
docs/federation.md Normal file
View file

@ -0,0 +1,23 @@
# Federation
This section will cover how federation works in Toolshed.
## What is Federation?
Since user of Toolshed you can search and interact the inventory of all their 'friends' that are potentially on
different servers there is a need for a way to communicate between servers. We don't want to rely on a central server that
stores all the data and we don't want to have a central server that handles all the communication between servers. This
is where federation comes in. Toolshed uses a protocol that can not only exchange data with the server where the user
is registered but also with the servers where their friends are registered.
## How does it work?
Any user can register on any server and creates a personal key pair. The public key is stored on the server and the private
key is stored on the client. The private key is used to sign all requests to the server and the public key is used to
verify the signature. Once a user has registered on a server they can send friend requests to other users containing
their public key. If the other user accepts the friend request, the server stores the public key of the friend and
uses it to verify access to the friend's inventory. While accepting a friend request the user also automatically sends
their own public key to the friend's server. This way both users can access each other's inventory.
The protocol is based on a simple HTTPS API exchanging JSON data that is signed with the user's private key. By default
Toolshed servers provide a documentation of the API at [/docs/api](/docs/api).

View file

@ -6,47 +6,8 @@ This is the documentation for the Toolshed project. It is a work in progress.
`#social` `#network` `#federation` `#decentralized` `#federated` `#socialnetwork` `#fediverse` `#community` `#hashtags`
## Getting Started
- [Deploying Toolshed](deployment.md)
- [Development Setup](development.md)
- [About Federation](federation.md)
## Installation
``` bash
# TODO add installation instructions
# similar to development instructions just with more docker
# TODO add docker-compose.yml
```
## Development
``` bash
git clone https://github.com/gr4yj3d1/toolshed.git
```
or
``` bash
git clone https://git.neulandlabor.de/j3d1/toolshed.git
```
### Frontend
``` bash
cd toolshed/frontend
npm install
npm run dev
```
### Backend
``` bash
cd toolshed/backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
```
### Docs
``` bash
cd toolshed/docs
mkdocs serve -a 0.0.0.0:8080
```

View file

@ -1,14 +0,0 @@
FROM node:alpine as builder
WORKDIR /app
COPY ./package.json /app/package.json
COPY . /app
RUN npm install
RUN npm run build
FROM nginx:alpine as runner
RUN apk add --update npm
WORKDIR /app
COPY --from=builder /app/dist /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/nginx.conf
EXPOSE 80

View file

@ -1,87 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIEXTCCA0WgAwIBAgISBEHk0Sh8UrMfT+VPkRhr83mfMA0GCSqGSIb3DQEBCwUA
MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD
EwJSMzAeFw0yMzA2MDIxNzI1MDVaFw0yMzA4MzExNzI1MDRaMBsxGTAXBgNVBAMT
EHRvb2xzaGVkLmozZDEuZGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYeMpT
k1DaC8cigL3DivanGrLQahYBEDm5B26VaS3gUmq9T0RNkEUxJIPnZBwdF8p7xAEB
hlTXwgy3eBLAp8lAo4ICTTCCAkkwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTRtqOn
Qht7rD+2IEGzuYt8frTgVjAfBgNVHSMEGDAWgBQULrMXt1hWy65QCUDmH6+dixTC
xjBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6Ly9yMy5vLmxlbmNy
Lm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL3IzLmkubGVuY3Iub3JnLzAbBgNVHREE
FDASghB0b29sc2hlZC5qM2QxLmRlMEwGA1UdIARFMEMwCAYGZ4EMAQIBMDcGCysG
AQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5cHQu
b3JnMIIBBgYKKwYBBAHWeQIEAgSB9wSB9ADyAHcAtz77JN+cTbp18jnFulj0bF38
Qs96nzXEnh0JgSXttJkAAAGIfVskxgAABAMASDBGAiEA+D8rCaCpttJm7w0M4N5N
3cmJSfPNmh/t2ojaDB0iSe0CIQCS2XkwJzUrDZ35fIJ9evwJduk/K2I/tmWs4Uk5
vnPSNQB3AK33vvp8/xDIi509nB4+GGq0Zyldz7EMJMqFhjTr3IKKAAABiH1bJOoA
AAQDAEgwRgIhAPHQQwLf5xSi1VH6BeOpiUKyTMawd36FFU8eCIdB43q6AiEA0KDD
yRssPcmGnyWDGq9Of3mpKjCChFrnxzpeDXCTlsswDQYJKoZIhvcNAQELBQADggEB
AKo8APReSKNTydks9yqASKhUjuLfXS+mpFQSl2tbU8ER6eIiYHx8o+n2QCdT7h91
ZLkGx8ZAmWBvVwXC3QPH5W08ilogi4EU/+HGffkditG5K6/Qn2bzjqnmFIyYqgdT
RVaRcxqS9byAGEw3oU5FSCIOuFSBeOHeTwaj+lSVMZTv6LmoovOpCo8sA5xZ6K6H
XVwNXIwunssaR4MrnWupB/5N+T7zkhanky4GgiLRuTm+mDbK+OIDx47Hv9jTe+tm
s4aixD0eWhzAaiA7HuHJI3Xi64YjK7eNlrwE0ZKdgy8KveDwUBiVcVtz7LR+0v1l
P27Z/OkZlA+42LvIJdISMl4=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
nLRbwHOoq7hHwg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB
AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC
ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL
wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D
LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK
4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5
bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y
sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ
Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4
FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc
SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql
PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND
TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1
c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx
+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB
ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu
b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E
U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu
MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC
5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW
9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG
WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O
he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC
Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5
-----END CERTIFICATE-----

View file

@ -1,14 +0,0 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
location /api {
proxy_pass http://django:8000;
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"bootstrap": "^4.6.2",
"bootstrap-icons-vue": "^1.10.3",
"dns-query": "^0.11.2",
"js-nacl": "^1.4.0",
@ -21,7 +22,7 @@
"@vitejs/plugin-vue": "^4.0.0",
"@vue/test-utils": "^2.3.2",
"jsdom": "^22.0.0",
"sass": "^1.62.1",
"sass": "^1.72.0",
"vite": "^4.1.4",
"vitest": "^0.31.1"
}

View file

@ -1,5 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgD6EmCAUWob1zUw4Q
F+Pf9cSOmSCTODe6u+Gst177IoihRANCAARYeMpTk1DaC8cigL3DivanGrLQahYB
EDm5B26VaS3gUmq9T0RNkEUxJIPnZBwdF8p7xAEBhlTXwgy3eBLAp8lA
-----END PRIVATE KEY-----

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 970 KiB

View file

@ -3,23 +3,13 @@
<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',
methods: {
...mapMutations(['init']),
},
beforeCreate () {
store.commit('load_local')
}
name: 'App'
}
</script>

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more