This commit is contained in:
parent
b3bae6f5ad
commit
bd59c40ac6
20 changed files with 431 additions and 25 deletions
|
@ -13,5 +13,4 @@ steps:
|
||||||
- apk add --no-cache gcc musl-dev python3-dev
|
- apk add --no-cache gcc musl-dev python3-dev
|
||||||
- pip install --upgrade pip && pip install -r requirements.txt
|
- pip install --upgrade pip && pip install -r requirements.txt
|
||||||
- python3 configure.py
|
- python3 configure.py
|
||||||
- coverage run manage.py test
|
- coverage run manage.py test && coverage report
|
||||||
- coverage report
|
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -130,4 +130,5 @@ dmypy.json
|
||||||
|
|
||||||
staticfiles/
|
staticfiles/
|
||||||
userfiles/
|
userfiles/
|
||||||
testdata.py
|
backend/templates/
|
||||||
|
backend/testdata.py
|
|
@ -5,7 +5,9 @@
|
||||||
``` bash
|
``` bash
|
||||||
git clone https://github.com/gr4yj3d1/toolshed.git
|
git clone https://github.com/gr4yj3d1/toolshed.git
|
||||||
```
|
```
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
git clone https://git.neulandlabor.de/j3d1/toolshed.git
|
git clone https://git.neulandlabor.de/j3d1/toolshed.git
|
||||||
```
|
```
|
||||||
|
@ -20,7 +22,9 @@ pip install -r requirements.txt
|
||||||
python configure.py
|
python configure.py
|
||||||
python manage.py runserver 0.0.0.0:8000 --insecure
|
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.
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@ -37,8 +41,6 @@ cd toolshed/docs
|
||||||
mkdocs serve
|
mkdocs serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## CLI Client
|
## CLI Client
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
|
@ -22,7 +22,8 @@ class KnownIdentity(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.username}@{self.domain}"
|
return f"{self.username}@{self.domain}"
|
||||||
|
|
||||||
def is_authenticated(self):
|
@staticmethod
|
||||||
|
def is_authenticated():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def friends_or_self(self):
|
def friends_or_self(self):
|
||||||
|
@ -30,9 +31,9 @@ class KnownIdentity(models.Model):
|
||||||
public_identity=self)
|
public_identity=self)
|
||||||
|
|
||||||
def verify(self, message, signature):
|
def verify(self, message, signature):
|
||||||
if len(signature) != 128 or type(signature) != str:
|
if len(signature) != 128 or not isinstance(signature, str):
|
||||||
raise TypeError('Signature must be 128 characters long and a string')
|
raise TypeError('Signature must be 128 characters long and a string')
|
||||||
if type(message) != str:
|
if not isinstance(message, str):
|
||||||
raise TypeError('Message must be a string')
|
raise TypeError('Message must be a string')
|
||||||
try:
|
try:
|
||||||
VerifyKey(bytes.fromhex(self.public_key)).verify(message.encode('utf-8'), bytes.fromhex(signature))
|
VerifyKey(bytes.fromhex(self.public_key)).verify(message.encode('utf-8'), bytes.fromhex(signature))
|
||||||
|
@ -44,14 +45,17 @@ class KnownIdentity(models.Model):
|
||||||
class ToolshedUserManager(auth.models.BaseUserManager):
|
class ToolshedUserManager(auth.models.BaseUserManager):
|
||||||
def create_user(self, username, email, password, **extra_fields):
|
def create_user(self, username, email, password, **extra_fields):
|
||||||
domain = extra_fields.pop('domain', 'localhost')
|
domain = extra_fields.pop('domain', 'localhost')
|
||||||
private_key_hex = extra_fields.pop('private_key', None)
|
private_key_hex: str | None = extra_fields.pop('private_key', None)
|
||||||
if private_key_hex and type(private_key_hex) != str:
|
if private_key_hex is not None:
|
||||||
|
if not isinstance(private_key_hex, str):
|
||||||
raise TypeError('Private key must be a string or no private key must be provided')
|
raise TypeError('Private key must be a string or no private key must be provided')
|
||||||
if private_key_hex and len(private_key_hex) != 64:
|
if len(private_key_hex) != 64:
|
||||||
raise ValueError('Private key must be 64 characters long or no private key must be provided')
|
raise ValueError('Private key must be 64 characters long or no private key must be provided')
|
||||||
if private_key_hex and not all(c in '0123456789abcdef' for c in private_key_hex):
|
if not all(c in '0123456789abcdef' for c in private_key_hex):
|
||||||
raise ValueError('Private key must be a hexadecimal string or no private key must be provided')
|
raise ValueError('Private key must be a hexadecimal string or no private key must be provided')
|
||||||
private_key = SigningKey(bytes.fromhex(private_key_hex)) if private_key_hex else SigningKey.generate()
|
private_key = SigningKey(bytes.fromhex(private_key_hex))
|
||||||
|
else:
|
||||||
|
private_key = SigningKey.generate()
|
||||||
public_key = SigningKey(private_key.encode()).verify_key
|
public_key = SigningKey(private_key.encode()).verify_key
|
||||||
extra_fields['private_key'] = private_key.encode(encoder=HexEncoder).decode('utf-8')
|
extra_fields['private_key'] = private_key.encode(encoder=HexEncoder).decode('utf-8')
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import Client, RequestFactory
|
from django.test import Client, RequestFactory
|
||||||
from nacl.encoding import HexEncoder
|
from nacl.encoding import HexEncoder
|
||||||
from nacl.signing import SigningKey
|
from nacl.signing import SigningKey
|
||||||
|
|
|
@ -13,11 +13,14 @@ Including another URLconf
|
||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from drf_yasg import openapi
|
from drf_yasg import openapi
|
||||||
from drf_yasg.views import get_schema_view
|
from drf_yasg.views import get_schema_view
|
||||||
|
|
||||||
|
from backend import settings
|
||||||
|
|
||||||
openapi_info = openapi.Info(
|
openapi_info = openapi.Info(
|
||||||
title="Toolshed API",
|
title="Toolshed API",
|
||||||
default_version='v1',
|
default_version='v1',
|
||||||
|
@ -35,9 +38,10 @@ urlpatterns = [
|
||||||
path('auth/', include('authentication.api')),
|
path('auth/', include('authentication.api')),
|
||||||
path('admin/', include('hostadmin.api')),
|
path('admin/', include('hostadmin.api')),
|
||||||
path('api/', include('toolshed.api.friend')),
|
path('api/', include('toolshed.api.friend')),
|
||||||
|
path('api/', include('toolshed.api.social')),
|
||||||
path('api/', include('toolshed.api.inventory')),
|
path('api/', include('toolshed.api.inventory')),
|
||||||
path('api/', include('toolshed.api.info')),
|
path('api/', include('toolshed.api.info')),
|
||||||
path('api/', include('toolshed.api.files')),
|
path('api/', include('toolshed.api.files')),
|
||||||
path('media/', include('files.media_urls')),
|
path('media/', include('files.media_urls')),
|
||||||
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
|
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
|
||||||
]
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
|
@ -74,10 +74,18 @@ def configure():
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
call_command('migrate')
|
call_command('migrate')
|
||||||
|
|
||||||
|
# if yesno("Do you want to create initial domains?"):
|
||||||
|
# domains = input("Enter a comma-separated list of allowed hosts: ")
|
||||||
|
# from hostadmin import Domain
|
||||||
|
#
|
||||||
|
# Domain.objects.
|
||||||
|
|
||||||
|
# TODO check if superuser exists
|
||||||
if yesno("Do you want to create a superuser?"):
|
if yesno("Do you want to create a superuser?"):
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
call_command('createsuperuser')
|
call_command('createsuperuser')
|
||||||
|
|
||||||
|
# TODO ask for which static directory to use and save it in .env
|
||||||
call_command('collectstatic', '--no-input')
|
call_command('collectstatic', '--no-input')
|
||||||
|
|
||||||
if yesno("Do you want to import all categories, properties and tags contained in this repository?", default=True):
|
if yesno("Do you want to import all categories, properties and tags contained in this repository?", default=True):
|
||||||
|
|
59
backend/shared_data/base.json
Normal file
59
backend/shared_data/base.json
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{ "name": "hardware"},
|
||||||
|
{ "name": "material"},
|
||||||
|
{ "name": "tools"}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{ "name": "angle", "unit_symbol": "°", "unit_name": "degree", "unit_name_plural": "degrees" },
|
||||||
|
{ "name": "area", "unit_symbol": "m²", "unit_name": "square meter", "unit_name_plural": "square meters" },
|
||||||
|
{ "name": "current", "unit_symbol": "A", "unit_name": "ampere", "unit_name_plural": "amperes" },
|
||||||
|
{ "name": "diameter", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "energy", "unit_symbol": "J", "unit_name": "joule", "unit_name_plural": "joules" },
|
||||||
|
{ "name": "frequency", "unit_symbol": "Hz", "unit_name": "hertz", "unit_name_plural": "hertz" },
|
||||||
|
{ "name": "height", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "length", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
|
||||||
|
{ "name": "memory", "unit_symbol": "B", "unit_name": "byte", "unit_name_plural": "bytes", "base2_prefix": true },
|
||||||
|
{ "name": "power", "unit_symbol": "W", "unit_name": "watt", "unit_name_plural": "watts" },
|
||||||
|
{ "name": "price", "unit_symbol": "€", "unit_name": "euro", "unit_name_plural": "euros" },
|
||||||
|
{ "name": "speed", "unit_symbol": "m/s", "unit_name": "meter per second", "unit_name_plural": "meters per second" },
|
||||||
|
{ "name": "temperature", "unit_symbol": "°C", "unit_name": "degree Celsius", "unit_name_plural": "degrees Celsius" },
|
||||||
|
{ "name": "time", "unit_symbol": "s", "unit_name": "second", "unit_name_plural": "seconds" },
|
||||||
|
{ "name": "voltage", "unit_symbol": "V", "unit_name": "volt", "unit_name_plural": "volts" },
|
||||||
|
{ "name": "volume", "unit_symbol": "l", "unit_name": "liter", "unit_name_plural": "liters" },
|
||||||
|
{ "name": "weight", "unit_symbol": "g", "unit_name": "gram", "unit_name_plural": "grams" },
|
||||||
|
{ "name": "width", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" }
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{"name": "bolt", "category": "hardware"},
|
||||||
|
{"name": "chisel", "category": "tools"},
|
||||||
|
{"name": "clamp", "category": "tools"},
|
||||||
|
{"name": "drill", "category": "tools"},
|
||||||
|
{"name": "ear plugs", "category": "tools"},
|
||||||
|
{"name": "extension cord", "category": "tools"},
|
||||||
|
{"name": "flashlight", "category": "tools"},
|
||||||
|
{"name": "gloves", "category": "tools"},
|
||||||
|
{"name": "goggles", "category": "tools"},
|
||||||
|
{"name": "hammer", "category": "tools"},
|
||||||
|
{"name": "level", "category": "tools"},
|
||||||
|
{"name": "mask", "category": "tools"},
|
||||||
|
{"name": "nail", "category": "hardware"},
|
||||||
|
{"name": "nut", "category": "hardware"},
|
||||||
|
{"name": "paint brush", "category": "tools"},
|
||||||
|
{"name": "paint roller", "category": "tools"},
|
||||||
|
{"name": "paint tray", "category": "tools"},
|
||||||
|
{"name": "pliers", "category": "tools"},
|
||||||
|
{"name": "power strip", "category": "tools"},
|
||||||
|
{"name": "sander", "category": "tools"},
|
||||||
|
{"name": "saw", "category": "tools"},
|
||||||
|
{"name": "screw", "category": "hardware"},
|
||||||
|
{"name": "screwdriver", "category": "tools"},
|
||||||
|
{"name": "soldering iron", "category": "tools"},
|
||||||
|
{"name": "stapler", "category": "tools"},
|
||||||
|
{"name": "tape measure", "category": "tools"},
|
||||||
|
{"name": "tool"},
|
||||||
|
{"name": "vise", "category": "tools"},
|
||||||
|
{"name": "washer", "category": "hardware"},
|
||||||
|
{"name": "wrench", "category": "tools"}
|
||||||
|
]
|
||||||
|
}
|
30
backend/shared_data/screws.json
Normal file
30
backend/shared_data/screws.json
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"depends": [ "git:base" ],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "screws", "parent": "hardware"}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{"name": "m1", "category": "screws"},
|
||||||
|
{"name": "m2", "category": "screws"},
|
||||||
|
{"name": "m2.5", "category": "screws"},
|
||||||
|
{"name": "m3", "category": "screws"},
|
||||||
|
{"name": "m4", "category": "screws"},
|
||||||
|
{"name": "m5", "category": "screws"},
|
||||||
|
{"name": "m6", "category": "screws"},
|
||||||
|
{"name": "m8", "category": "screws"},
|
||||||
|
{"name": "m10", "category": "screws"},
|
||||||
|
{"name": "m12", "category": "screws"},
|
||||||
|
{"name": "m16", "category": "screws"},
|
||||||
|
{"name": "torx", "category": "screws"},
|
||||||
|
{"name": "hex", "category": "screws"},
|
||||||
|
{"name": "phillips", "category": "screws"},
|
||||||
|
{"name": "pozidriv", "category": "screws"},
|
||||||
|
{"name": "slotted", "category": "screws"},
|
||||||
|
{"name": "socket", "category": "screws"},
|
||||||
|
{"name": "flat", "category": "screws"},
|
||||||
|
{"name": "pan", "category": "screws"},
|
||||||
|
{"name": "button", "category": "screws"},
|
||||||
|
{"name": "countersunk", "category": "screws"},
|
||||||
|
{"name": "round", "category": "screws"}
|
||||||
|
]
|
||||||
|
}
|
|
@ -1,6 +1,14 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from toolshed.models import InventoryItem, Property, Tag, ItemProperty, ItemTag
|
from toolshed.models import Profile, InventoryItem, Property, Tag, ItemProperty, ItemTag
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'bio', 'location')
|
||||||
|
search_fields = ('user', 'bio', 'location')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Profile, ProfileAdmin)
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemAdmin(admin.ModelAdmin):
|
class InventoryItemAdmin(admin.ModelAdmin):
|
||||||
|
@ -25,3 +33,26 @@ class TagAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Tag, TagAdmin)
|
admin.site.register(Tag, TagAdmin)
|
||||||
|
|
||||||
|
# class ItemPropertyAdmin(admin.ModelAdmin):
|
||||||
|
# list_display = ('item', 'property', 'value')
|
||||||
|
# search_fields = ('item', 'property', 'value')
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# admin.site.register(ItemProperty, ItemPropertyAdmin)
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# class ItemTagAdmin(admin.ModelAdmin):
|
||||||
|
# list_display = ('item', 'tag')
|
||||||
|
# search_fields = ('item', 'tag')
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# admin.site.register(ItemTag, ItemTagAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
# class LendingPeriodAdmin(admin.ModelAdmin):
|
||||||
|
# list_display = ('item', 'start_date', 'end_date')
|
||||||
|
# search_fields = ('item', 'start_date', 'end_date')
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# admin.site.register(LendingPeriod, LendingPeriodAdmin)
|
||||||
|
|
16
backend/toolshed/aggregators.py
Normal file
16
backend/toolshed/aggregators.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from toolshed.models import Event, Message
|
||||||
|
|
||||||
|
|
||||||
|
def timeline_notifications(user):
|
||||||
|
"""Return a list of notifications that the user is interested in."""
|
||||||
|
for evt in Event.objects.all():
|
||||||
|
if evt.user == user:
|
||||||
|
yield evt
|
||||||
|
for tool in user.inventory.all():
|
||||||
|
if evt.tool == tool:
|
||||||
|
yield evt
|
||||||
|
|
||||||
|
|
||||||
|
def unread_messages(user):
|
||||||
|
"""Return a list of unread messages."""
|
||||||
|
return Message.objects.filter(recipient=user, read=False)
|
|
@ -61,9 +61,11 @@ def combined_info(request, format=None): # /info/
|
||||||
tags = [tag.name for tag in Tag.objects.all()]
|
tags = [tag.name for tag in Tag.objects.all()]
|
||||||
properties = PropertySerializer(Property.objects.all(), many=True).data
|
properties = PropertySerializer(Property.objects.all(), many=True).data
|
||||||
categories = [str(category) for category in Category.objects.all()]
|
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)]
|
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 = [
|
urlpatterns = [
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework import routers, viewsets
|
from rest_framework import routers, viewsets, serializers
|
||||||
from rest_framework.decorators import authentication_classes, api_view, permission_classes
|
from rest_framework.decorators import authentication_classes, api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity
|
from authentication.models import ToolshedUser, KnownIdentity
|
||||||
from authentication.signature_auth import SignatureAuthentication
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
|
from files.models import File
|
||||||
from toolshed.models import InventoryItem, StorageLocation
|
from toolshed.models import InventoryItem, StorageLocation
|
||||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer
|
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer
|
||||||
|
|
||||||
|
|
40
backend/toolshed/api/social.py
Normal file
40
backend/toolshed/api/social.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from django.urls import path
|
||||||
|
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from authentication.signature_auth import SignatureAuthenticationLocal
|
||||||
|
from toolshed.models import Message, Profile
|
||||||
|
from toolshed.serializers import MessageSerializer, ProfileSerializer
|
||||||
|
from toolshed.aggregators import unread_messages, timeline_notifications
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_messages(request):
|
||||||
|
messages = Message.objects.filter(recipient=request.user)
|
||||||
|
return Response(MessageSerializer(messages, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_profile(request):
|
||||||
|
profile = Profile.objects.get(user=request.user)
|
||||||
|
return Response(ProfileSerializer(profile).data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_notifications(request):
|
||||||
|
notifications = timeline_notifications(request.user)
|
||||||
|
return Response(notifications)
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('messages/', get_messages),
|
||||||
|
path('profile/', get_profile),
|
||||||
|
path('notifications/', get_notifications),
|
||||||
|
]
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Generated by Django 4.2.2 on 2024-02-23 15:30
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('files', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('toolshed', '0005_alter_inventoryitem_availability_policy'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Event',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('location', models.CharField(max_length=255)),
|
||||||
|
('date', models.DateField()),
|
||||||
|
('time', models.TimeField()),
|
||||||
|
('host_username', models.CharField(max_length=255)),
|
||||||
|
('host_domain', models.CharField(max_length=255)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Transaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='pending', max_length=20)),
|
||||||
|
('message', models.TextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('item_offered', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offered_transactions', to='toolshed.inventoryitem')),
|
||||||
|
('item_requested', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requested_transactions', to='toolshed.inventoryitem')),
|
||||||
|
('offerer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offered_transactions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requested_transactions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Profile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('bio', models.TextField(blank=True)),
|
||||||
|
('location', models.CharField(blank=True, max_length=255)),
|
||||||
|
('profile_picture', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='files.file')),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Message',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('subject', models.CharField(max_length=255)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('read', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,4 +1,6 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save, pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django_softdelete.models import SoftDeleteModel
|
from django_softdelete.models import SoftDeleteModel
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
@ -7,6 +9,52 @@ from authentication.models import ToolshedUser, KnownIdentity
|
||||||
from files.models import File
|
from files.models import File
|
||||||
|
|
||||||
|
|
||||||
|
# @receiver(pre_save)
|
||||||
|
# def pre_save_handler(sender, instance, *args, **kwargs):
|
||||||
|
# instance.full_clean()
|
||||||
|
|
||||||
|
|
||||||
|
class Event(models.Model):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField()
|
||||||
|
location = models.CharField(max_length=255)
|
||||||
|
date = models.DateField()
|
||||||
|
time = models.TimeField()
|
||||||
|
# host = models.ForeignKey(User, on_delete=models.CASCADE, related_name='events')
|
||||||
|
host_username = models.CharField(max_length=255)
|
||||||
|
host_domain = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
# def __str__(self):
|
||||||
|
# return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Message(models.Model):
|
||||||
|
sender = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='sent_messages')
|
||||||
|
recipient = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='received_messages')
|
||||||
|
subject = models.CharField(max_length=255)
|
||||||
|
body = models.TextField()
|
||||||
|
read = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
user = models.OneToOneField(ToolshedUser, on_delete=models.CASCADE)
|
||||||
|
profile_picture = models.ForeignKey(File, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
bio = models.TextField(blank=True)
|
||||||
|
location = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
@receiver(post_save, sender=ToolshedUser)
|
||||||
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
Profile.objects.create(user=instance)
|
||||||
|
|
||||||
|
@receiver(post_save, sender=ToolshedUser)
|
||||||
|
def save_user_profile(sender, instance, **kwargs):
|
||||||
|
instance.profile.save()
|
||||||
|
|
||||||
|
|
||||||
class Category(SoftDeleteModel):
|
class Category(SoftDeleteModel):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
|
@ -29,6 +77,7 @@ class Property(models.Model):
|
||||||
unit_name = models.CharField(max_length=255, 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)
|
unit_name_plural = models.CharField(max_length=255, null=True, blank=True)
|
||||||
base2_prefix = models.BooleanField(default=False)
|
base2_prefix = models.BooleanField(default=False)
|
||||||
|
# sort_lexicographically = models.BooleanField(default=False)
|
||||||
dimensions = models.IntegerField(null=False, blank=False, default=1, validators=[MinValueValidator(1)])
|
dimensions = models.IntegerField(null=False, blank=False, default=1, validators=[MinValueValidator(1)])
|
||||||
origin = models.CharField(max_length=255, null=False, blank=False)
|
origin = models.CharField(max_length=255, null=False, blank=False)
|
||||||
|
|
||||||
|
@ -89,6 +138,23 @@ class ItemTag(models.Model):
|
||||||
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
|
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('accepted', 'Accepted'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
)
|
||||||
|
|
||||||
|
item_requested = models.ForeignKey(InventoryItem, on_delete=models.CASCADE, related_name='requested_transactions')
|
||||||
|
item_offered = models.ForeignKey(InventoryItem, on_delete=models.CASCADE, related_name='offered_transactions')
|
||||||
|
requester = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='requested_transactions')
|
||||||
|
offerer = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='offered_transactions')
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
|
message = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|
||||||
class StorageLocation(models.Model):
|
class StorageLocation(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
|
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
|
||||||
from authentication.serializers import OwnerSerializer
|
from authentication.serializers import OwnerSerializer
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from files.serializers import FileSerializer
|
from files.serializers import FileSerializer
|
||||||
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation
|
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, Profile, Message, StorageLocation
|
||||||
|
|
||||||
|
|
||||||
class FriendSerializer(serializers.ModelSerializer):
|
class FriendSerializer(serializers.ModelSerializer):
|
||||||
|
@ -28,6 +29,18 @@ class FriendRequestSerializer(serializers.ModelSerializer):
|
||||||
return obj.befriender_username + '@' + obj.befriender_domain
|
return obj.befriender_username + '@' + obj.befriender_domain
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSerializer(serializers.Serializer):
|
||||||
|
class Meta:
|
||||||
|
model = Profile
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSerializer(serializers.Serializer):
|
||||||
|
class Meta:
|
||||||
|
model = Message
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class PropertySerializer(serializers.ModelSerializer):
|
class PropertySerializer(serializers.ModelSerializer):
|
||||||
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
|
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
|
||||||
|
|
||||||
|
@ -103,6 +116,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
tags = validated_data.pop('tags', [])
|
tags = validated_data.pop('tags', [])
|
||||||
props = validated_data.pop('itemproperty_set', [])
|
props = validated_data.pop('itemproperty_set', [])
|
||||||
files = validated_data.pop('files', [])
|
files = validated_data.pop('files', [])
|
||||||
|
# if 'category' in validated_data and validated_data['category'] == '':
|
||||||
|
# validated_data.pop('category')
|
||||||
item = InventoryItem.objects.create(**validated_data)
|
item = InventoryItem.objects.create(**validated_data)
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
item.tags.add(tag, through_defaults={})
|
item.tags.add(tag, through_defaults={})
|
||||||
|
@ -127,6 +142,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
tags = validated_data.pop('tags', [])
|
tags = validated_data.pop('tags', [])
|
||||||
props = validated_data.pop('itemproperty_set', [])
|
props = validated_data.pop('itemproperty_set', [])
|
||||||
|
# if 'category' in validated_data and validated_data['category'] == '':
|
||||||
|
# validated_data.pop('category')
|
||||||
item = super().update(instance, validated_data)
|
item = super().update(instance, validated_data)
|
||||||
item.tags.clear()
|
item.tags.clear()
|
||||||
item.properties.clear()
|
item.properties.clear()
|
||||||
|
|
|
@ -53,7 +53,8 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
|
||||||
def test_combined_api(self):
|
def test_combined_api(self):
|
||||||
response = client.get('/api/info/', self.f['local_user1'])
|
response = client.get('/api/info/', self.f['local_user1'])
|
||||||
self.assertEqual(response.status_code, 200)
|
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'],
|
self.assertEqual(response.json()['categories'],
|
||||||
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat3'])
|
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat3'])
|
||||||
self.assertEqual(response.json()['tags'], ['tag1', 'tag2', 'tag3'])
|
self.assertEqual(response.json()['tags'], ['tag1', 'tag2', 'tag3'])
|
||||||
|
|
|
@ -369,13 +369,29 @@ class FriendRequestOutgoingTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(befriendee.friends.first().username, befriender.username)
|
self.assertEqual(befriendee.friends.first().username, befriender.username)
|
||||||
self.assertEqual(befriendee.friends.first().domain, befriender.domain)
|
self.assertEqual(befriendee.friends.first().domain, befriender.domain)
|
||||||
|
|
||||||
|
# TODO:
|
||||||
|
# - test that the friend request is deleted after a certain amount of time
|
||||||
|
# - decline friend request Endpoint ('reject'?)
|
||||||
|
# - cancel friend request Endpoint ('retract'?)
|
||||||
|
# - drop friend Endpoint
|
||||||
|
# Szenarios: (all also with broken signature, wrong key, wrong secret, wrong author and without authorisation header)
|
||||||
|
# (plus: friend request exists already, already friends)
|
||||||
|
# - local1 requests local2, local2 accepts
|
||||||
|
# - local1 requests local2, local2 declines
|
||||||
|
# - local1 requests local2, local1 cancels
|
||||||
|
# - ext requests local, local accepts
|
||||||
|
# - ext requests local, local declines
|
||||||
|
# - ext requests local, ext cancels
|
||||||
|
# - local requests ext, ext accepts
|
||||||
|
# - local requests ext, ext declines
|
||||||
|
# - local requests ext, local cancels
|
||||||
|
|
||||||
|
|
||||||
class FriendRequestCombinedTestCase(UserTestMixin, ToolshedTestCase):
|
class FriendRequestCombinedTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.prepare_users()
|
self.prepare_users()
|
||||||
print(self.f)
|
|
||||||
|
|
||||||
def test_friend_request_combined(self):
|
def test_friend_request_combined(self):
|
||||||
befriender = self.f['local_user1']
|
befriender = self.f['local_user1']
|
||||||
|
|
41
backend/toolshed/tests/test_social.py
Normal file
41
backend/toolshed/tests/test_social.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
|
||||||
|
|
||||||
|
anonymous_client = Client()
|
||||||
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
||||||
|
class MessageApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
|
||||||
|
def test_get_messages(self):
|
||||||
|
response = client.get('/api/messages/', self.f['local_user1'])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
|
||||||
|
def test_get_profile(self):
|
||||||
|
response = client.get('/api/profile/', self.f['local_user1'])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationApiTestCase(UserTestMixin, ToolshedTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
|
||||||
|
def test_get_notifications(self):
|
||||||
|
response = client.get('/api/notifications/', self.f['local_user1'])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), [])
|
Loading…
Reference in a new issue