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
|
||||
- pip install --upgrade pip && pip install -r requirements.txt
|
||||
- python3 configure.py
|
||||
- coverage run manage.py test
|
||||
- coverage report
|
||||
- coverage run manage.py test && coverage report
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -130,4 +130,5 @@ dmypy.json
|
|||
|
||||
staticfiles/
|
||||
userfiles/
|
||||
testdata.py
|
||||
backend/templates/
|
||||
backend/testdata.py
|
|
@ -5,7 +5,9 @@
|
|||
``` bash
|
||||
git clone https://github.com/gr4yj3d1/toolshed.git
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
``` bash
|
||||
git clone https://git.neulandlabor.de/j3d1/toolshed.git
|
||||
```
|
||||
|
@ -20,7 +22,9 @@ pip install -r requirements.txt
|
|||
python configure.py
|
||||
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
|
||||
|
||||
|
@ -37,8 +41,6 @@ cd toolshed/docs
|
|||
mkdocs serve
|
||||
```
|
||||
|
||||
|
||||
|
||||
## CLI Client
|
||||
|
||||
### Requirements
|
||||
|
|
|
@ -22,7 +22,8 @@ class KnownIdentity(models.Model):
|
|||
def __str__(self):
|
||||
return f"{self.username}@{self.domain}"
|
||||
|
||||
def is_authenticated(self):
|
||||
@staticmethod
|
||||
def is_authenticated():
|
||||
return True
|
||||
|
||||
def friends_or_self(self):
|
||||
|
@ -30,9 +31,9 @@ class KnownIdentity(models.Model):
|
|||
public_identity=self)
|
||||
|
||||
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')
|
||||
if type(message) != str:
|
||||
if not isinstance(message, str):
|
||||
raise TypeError('Message must be a string')
|
||||
try:
|
||||
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):
|
||||
def create_user(self, username, email, password, **extra_fields):
|
||||
domain = extra_fields.pop('domain', 'localhost')
|
||||
private_key_hex = extra_fields.pop('private_key', None)
|
||||
if private_key_hex and type(private_key_hex) != str:
|
||||
private_key_hex: str | None = extra_fields.pop('private_key', None)
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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
|
||||
extra_fields['private_key'] = private_key.encode(encoder=HexEncoder).decode('utf-8')
|
||||
try:
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import Client, RequestFactory
|
||||
from nacl.encoding import HexEncoder
|
||||
from nacl.signing import SigningKey
|
||||
|
|
|
@ -13,11 +13,14 @@ Including another URLconf
|
|||
1. Import the include() function: from django.urls import include, path
|
||||
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.urls import path, include
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.views import get_schema_view
|
||||
|
||||
from backend import settings
|
||||
|
||||
openapi_info = openapi.Info(
|
||||
title="Toolshed API",
|
||||
default_version='v1',
|
||||
|
@ -35,9 +38,10 @@ urlpatterns = [
|
|||
path('auth/', include('authentication.api')),
|
||||
path('admin/', include('hostadmin.api')),
|
||||
path('api/', include('toolshed.api.friend')),
|
||||
path('api/', include('toolshed.api.social')),
|
||||
path('api/', include('toolshed.api.inventory')),
|
||||
path('api/', include('toolshed.api.info')),
|
||||
path('api/', include('toolshed.api.files')),
|
||||
path('media/', include('files.media_urls')),
|
||||
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
|
||||
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?"):
|
||||
from django.core.management import call_command
|
||||
call_command('createsuperuser')
|
||||
|
||||
# TODO ask for which static directory to use and save it in .env
|
||||
call_command('collectstatic', '--no-input')
|
||||
|
||||
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 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):
|
||||
|
@ -25,3 +33,26 @@ class TagAdmin(admin.ModelAdmin):
|
|||
|
||||
|
||||
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()]
|
||||
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 = [
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
from django.db import transaction
|
||||
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.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.signature_auth import SignatureAuthentication
|
||||
from files.models import File
|
||||
from toolshed.models import InventoryItem, StorageLocation
|
||||
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.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.validators import MinValueValidator
|
||||
from django_softdelete.models import SoftDeleteModel
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
@ -7,6 +9,52 @@ from authentication.models import ToolshedUser, KnownIdentity
|
|||
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):
|
||||
name = models.CharField(max_length=255, unique=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_plural = models.CharField(max_length=255, null=True, blank=True)
|
||||
base2_prefix = models.BooleanField(default=False)
|
||||
# sort_lexicographically = models.BooleanField(default=False)
|
||||
dimensions = models.IntegerField(null=False, blank=False, default=1, validators=[MinValueValidator(1)])
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
|
||||
from authentication.serializers import OwnerSerializer
|
||||
from files.models import File
|
||||
from files.serializers import FileSerializer
|
||||
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation
|
||||
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, Profile, Message, StorageLocation
|
||||
|
||||
|
||||
class FriendSerializer(serializers.ModelSerializer):
|
||||
|
@ -28,6 +29,18 @@ class FriendRequestSerializer(serializers.ModelSerializer):
|
|||
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):
|
||||
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name')
|
||||
|
||||
|
@ -103,6 +116,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
|||
tags = validated_data.pop('tags', [])
|
||||
props = validated_data.pop('itemproperty_set', [])
|
||||
files = validated_data.pop('files', [])
|
||||
# if 'category' in validated_data and validated_data['category'] == '':
|
||||
# validated_data.pop('category')
|
||||
item = InventoryItem.objects.create(**validated_data)
|
||||
for tag in tags:
|
||||
item.tags.add(tag, through_defaults={})
|
||||
|
@ -127,6 +142,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
|||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
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.tags.clear()
|
||||
item.properties.clear()
|
||||
|
|
|
@ -53,7 +53,8 @@ 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'])
|
||||
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().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):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_users()
|
||||
print(self.f)
|
||||
|
||||
def test_friend_request_combined(self):
|
||||
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