pull/3/head
lalanza808 5 years ago
commit 7478caf99b

9
.gitignore vendored

@ -0,0 +1,9 @@
.venv
__pycache__
.DS_Store
.idea
*.pyc
.env
/data
/media
.coverage

@ -0,0 +1,5 @@
from django.contrib import admin
from bids.models import ItemBid
admin.site.register(ItemBid)

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BidsConfig(AppConfig):
name = 'bids'

@ -0,0 +1,11 @@
from django import forms
from bids.models import ItemBid
class CreateItemBidForm(forms.ModelForm):
class Meta:
model = ItemBid
fields = ['bid_price_xmr']
labels = {
'bid_price_xmr': 'Bid Price (XMR)'
}

@ -0,0 +1,30 @@
# Generated by Django 2.2.7 on 2019-12-13 05:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('items', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ItemBid',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bid_date', models.DateTimeField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('bid_price_xmr', models.FloatField()),
('accepted', models.BooleanField(default=False)),
('bidder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bidder', to=settings.AUTH_USER_MODEL)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bids', to='items.Item')),
],
),
]

@ -0,0 +1,15 @@
from django.db import models
from django.contrib.auth.models import User
from items.models import Item
class ItemBid(models.Model):
item = models.ForeignKey(Item, related_name='bids', on_delete=models.CASCADE)
bidder = models.ForeignKey(User, related_name='bidder', on_delete=models.CASCADE)
bid_date = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True)
bid_price_xmr = models.FloatField()
accepted = models.BooleanField(default=False)
def __str__(self):
return f"{self.id} - {self.item.name} - {self.bidder} > {self.item.owner}"

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.list_bids, name='list_bids'),
path('<int:bid_id>/accept/', views.accept_bid, name='accept_bid'),
path('<int:bid_id>/delete/', views.delete_bid, name='delete_bid'),
path('<int:bid_id>/edit/', views.edit_bid, name='edit_bid'),
path('item/<int:item_id>/create/', views.create_bid, name='create_bid'),
]

@ -0,0 +1,169 @@
from django.shortcuts import render, HttpResponseRedirect, reverse
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.conf import settings
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from bids.forms import CreateItemBidForm
from bids.models import ItemBid
from sales.models import ItemSale
from items.models import Item
from core.monero import AuctionWallet
@login_required
def list_bids(request):
page_query = request.GET.get('page', 1)
bid_list = ItemBid.objects.filter(bidder=request.user)
paginator = Paginator(bid_list, 20)
try:
bids = paginator.page(page_query)
except PageNotAnInteger:
bids = paginator.page(1)
except EmptyPage:
bids = paginator.page(paginator.num_pages)
context = {
'bids': bids
}
return render(request, 'bids/list_bids.html', context)
@login_required
def create_bid(request, item_id):
item = Item.objects.get(id=item_id)
current_user_bid = item.bids.filter(bidder=request.user).first()
# Do not allow bidding if current user is the owner
if request.user == item.owner:
messages.error(request, "You can't bid on an item you posted.")
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
# Do not allow bidding if item is not available
if item.available is False:
messages.error(request, "You can't bid on an item pending sale.")
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
# Redirect user to edit their existing bid if one exists
if current_user_bid:
return HttpResponseRedirect(reverse('edit_bid', args=[current_user_bid.id]))
if request.method == 'POST':
form = CreateItemBidForm(request.POST)
if form.is_valid():
bid = form.save(commit=False)
bid.bidder = request.user
bid.item = item
bid.save()
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
else:
context = {
'form': CreateItemBidForm(),
'item': item
}
return render(request, 'bids/create_bid.html', context)
@login_required
def edit_bid(request, bid_id):
bid = ItemBid.objects.get(id=bid_id)
# Do not allow editing if current user doesn't own the bid
if request.user != bid.bidder:
messages.error(request, "You can't edit a bid that doesn't belong to you.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
# Do not allow editing if bid is accepted already
if bid.accepted:
messages.error(request, "You can't edit a bid that has already been accepted.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
if request.method == 'POST':
form = CreateItemBidForm(request.POST, instance=bid)
if form.is_valid():
form.save()
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
else:
context = {
'form': CreateItemBidForm(instance=bid),
'bid': bid
}
return render(request, 'bids/edit_bid.html', context)
@login_required
def accept_bid(request, bid_id):
aw = AuctionWallet()
bid = ItemBid.objects.get(id=bid_id)
platform_fee_xmr = bid.bid_price_xmr * (settings.PLATFORM_FEE_PERCENT / 100)
expected_payment_xmr = bid.bid_price_xmr + platform_fee_xmr
account_label = f'Sale account for Item #{bid.item.id}, Bid #{bid.id}'
# Do not allow accepting your own bid
if request.user == bid.bidder:
messages.error(request, "You can't accept your own bid.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
# Do not allow accepting the bid unless you own the item that received the bid
if request.user != bid.item.owner:
messages.error(request, "You can't accept a bid if you don't own the item.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
# Do not proceed if item is not available
if bid.item.available is False:
messages.error(request, "You can't accept the bid because the item is pending sale.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
# Do not proceed if bid is already accepted
if bid.accepted:
messages.error(request, "You can't accept a bid if it has already been accepted.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
# Do not proceed if there platform wallet is not connected
if aw.connected is False:
messages.error(request, "You can't accept the bid because the platform wallet is not properly connected.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
# Item becomes unavailable
bid.item.available = False
bid.item.save()
# Bid becomes accepted
bid.accepted = True
bid.save()
# Generate new Monero account for the sale
new_account = aw.wallet.new_account(label=account_label)
# Sale is created
sale = ItemSale(
item=bid.item,
bid=bid,
escrow_address=new_account.address(),
escrow_account_index=new_account.index,
agreed_price_xmr=bid.bid_price_xmr,
platform_fee_xmr=platform_fee_xmr,
expected_payment_xmr=expected_payment_xmr
)
sale.save()
return HttpResponseRedirect(reverse('get_sale', args=[bid.id]))
@login_required
def delete_bid(request, bid_id):
bid = ItemBid.objects.get(id=bid_id)
# Do not allow deleting the bid unless you own the bid
if request.user != bid.bidder:
messages.error(request, "You can't delete a bid you did not create.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
# Do not allow deleting if the bid is accepted
if bid.accepted:
messages.error(request, "You can't delete a bid if it has been accepted.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))
bid.delete()
messages.success(request, f"Bid #{bid_id} on item \"{bid.item.name}\" ({bid.item.id}) has been deleted.")
return HttpResponseRedirect(reverse('get_item', args=[bid.item.id]))

@ -0,0 +1,5 @@
from django.contrib import admin
from core.models import UserShippingAddress
admin.site.register(UserShippingAddress)

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'

@ -0,0 +1,20 @@
from django import forms
from core.models import UserShippingAddress
class UserShippingAddressForm(forms.ModelForm):
class Meta:
model = UserShippingAddress
fields = [
'address1',
'address2',
'city',
'state',
'country',
'zip'
]
labels = {
'address1': 'Address',
'address2': 'Address (additional info)'
}

@ -0,0 +1,70 @@
from random import choice
from secrets import token_urlsafe
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from django.contrib.auth.models import User
from items.models import Item
from bids.models import ItemBid
class Command(BaseCommand):
help = 'Generates fake items within the application for testing'
def add_arguments(self, parser):
parser.add_argument('-i', '--items', type=int, help='Number of items to create', default=5)
def handle(self, *args, **kwargs):
dummy_data = {
'item_names': [
'Do Androids Dream of Electric Sheep?',
'The Hitchhiker\'s Guide to the Galaxy',
'Something Wicked This Way Comes',
'Pride and Prejudice and Zombies',
'The Curious Incident of the Dog in the Night-Time',
'I Was Told There\'d Be Cake',
'To Kill a Mockingbird',
'The Unbearable Lightness of Being',
'Eats, Shoots & Leaves: The Zero Tolerance Approach to Punctuation',
'The Hollow Chocolate Bunnies of the Apocalypse',
'A Clockwork Orange',
'Are You There, Vodka? It\'s Me, Chelsea'
],
'item_descriptions': [
'Brand new, never opened or used.',
'Light usage, good condition',
'Spilled some water on it, fair condition, but good enough',
'Mint condition - collectors item'
],
'item_ask_price': [
'.1', '.23', '.51', '.233', '.47', '.09'
],
'new_items': []
}
for index,value in enumerate(range(kwargs['items'])):
random_item = choice(dummy_data['item_names'])
random_desc = choice(dummy_data['item_descriptions'])
random_price = choice(dummy_data['item_ask_price'])
random_user = choice(User.objects.all())
item = Item(
owner=random_user,
name=random_item,
description=random_desc,
ask_price_xmr=random_price,
)
item.save()
dummy_data['new_items'].append(item)
self.stdout.write(self.style.SUCCESS(f'Item "{item.name} ({item.id})" created successfully!'))
for i in dummy_data['new_items']:
all_users = User.objects.all().exclude(username=i.owner.username)
for u in all_users:
bid = ItemBid(
item=i,
bidder=u,
bid_price_xmr=i.ask_price_xmr
)
bid.save()
self.stdout.write(self.style.SUCCESS(f'Bid #{bid.id} for user "{bid.bidder}" created successfully!'))

@ -0,0 +1,28 @@
from django.shortcuts import HttpResponseRedirect, reverse
from core.models import UserShippingAddress
class EnforceShippingAddressCreationMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# If current user is authenticated, get their shipping information and current page
# If current page is not them editing their address or logging out, redirect them
if request.user.is_authenticated:
profile = UserShippingAddress.objects.filter(user=request.user).first()
is_profile_absent = profile is None
allowed_paths = [
reverse('edit_shipping'),
reverse('logout')
]
on_allowed_path = request.path not in allowed_paths
if is_profile_absent and on_allowed_path:
return HttpResponseRedirect(reverse('edit_shipping'))
response = self.get_response(request)
return response

@ -0,0 +1,30 @@
# Generated by Django 2.2.7 on 2019-12-26 19:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserShippingAddress',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('address1', models.CharField(max_length=100)),
('address2', models.CharField(blank=True, max_length=100)),
('city', models.CharField(max_length=100)),
('state', models.CharField(max_length=60)),
('country', models.CharField(max_length=60)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
('zip', models.PositiveIntegerField()),
],
),
]

@ -0,0 +1,15 @@
from django.db import models
from django.contrib.auth.models import User
class UserShippingAddress(models.Model):
user = models.ForeignKey(User, related_name='profile', on_delete=models.CASCADE)
address1 = models.CharField(max_length=100)
address2 = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100)
state = models.CharField(max_length=60)
country = models.CharField(max_length=60)
zip = models.PositiveIntegerField()
def __str__(self):
return self.user.username

@ -0,0 +1,52 @@
from django.conf import settings
from monero.daemon import Daemon
from monero.wallet import Wallet
from monero.backends.jsonrpc import JSONRPCDaemon, JSONRPCWallet
class AuctionDaemon(object):
def __init__(self):
self.host = settings.DAEMON_HOST
self.port = settings.DAEMON_PORT
self.username = settings.DAEMON_USER
self.password = settings.DAEMON_PASS
self.daemon = Daemon(JSONRPCDaemon(
host=self.host,
port=self.port,
user=self.username,
password=self.password,
timeout=5
))
try:
status = self.daemon.info()['status']
if status == 'OK':
self.connected = True
else:
self.connected = False
except:
self.connected = False
class AuctionWallet(object):
def __init__(self):
self.host = settings.WALLET_HOST
self.port = settings.WALLET_PORT
self.username = settings.WALLET_USER
self.password = settings.WALLET_PASS
try:
self.wallet = Wallet(JSONRPCWallet(
host=self.host,
port=self.port,
user=self.username,
password=self.password,
timeout=5
))
if self.wallet:
self.connected = True
else:
self.connected = False
except:
self.connected = False

@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.home, name='home'),
path('shipping/edit/', views.edit_shipping, name='edit_shipping')
]

@ -0,0 +1,43 @@
from django.shortcuts import render, HttpResponseRedirect, reverse
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.contrib import messages
from core.forms import UserShippingAddressForm
from core.models import UserShippingAddress
from core.monero import AuctionDaemon
def home(request):
daemon = AuctionDaemon()
if daemon.connected:
daemon_info = daemon.daemon.info()
else:
daemon_info = False
return render(request, 'home.html', {'daemon_info': daemon_info})
@login_required
def edit_shipping(request):
profile = UserShippingAddress.objects.filter(user=request.user).first()
if request.method == 'POST':
form = UserShippingAddressForm(request.POST, instance=profile)
if form.is_valid():
saved_profile = form.save(commit=False)
saved_profile.user = request.user
saved_profile.save()
messages.success(request, 'Profile updated.')
return HttpResponseRedirect(reverse('home'))
else:
messages.error(request, 'Unable to save shipping information.')
form_errors = form.errors.get_json_data()
for err in form_errors:
err_data = form_errors[err][0]
messages.error(request, f'{err}: {err_data["message"]}')
context = {
'form': UserShippingAddressForm(instance=profile)
}
return render(request, 'core/edit_shipping.html', context)

@ -0,0 +1,16 @@
version: '3'
services:
db:
image: postgres:9.6.15-alpine
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_USER: ${DB_USER}
POSTGRES_DB: ${DB_NAME}
volumes:
- ./data/postgresql:/var/lib/postgresql/data
cache:
image: redis:5.0.7-buster
ports:
- 6379:6379

@ -0,0 +1,6 @@
from django.contrib import admin
from items.models import Item, ItemImage
admin.site.register(Item)
admin.site.register(ItemImage)

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ItemsConfig(AppConfig):
name = 'items'

@ -0,0 +1,17 @@
from django import forms
from items.models import Item, address_is_valid_monero
class CreateItemForm(forms.ModelForm):
payout_address = forms.CharField(validators=[address_is_valid_monero])
class Meta:
model = Item
fields = ['name', 'description', 'ask_price_xmr', 'payout_address']
labels = {
'ask_price_xmr': 'Asking Price (XMR)',
'payout_address': 'Payout Wallet Address'
}
help_texts = {
'payout_address': 'Monero address where funds will be sent after sale is confirmed'
}

@ -0,0 +1,40 @@
# Generated by Django 2.2.7 on 2019-12-24 08:08
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import items.models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Item',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('list_date', models.DateTimeField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('description', models.TextField(max_length=500)),
('ask_price_xmr', models.FloatField()),
('available', models.BooleanField(default=True)),
('payout_address', models.CharField(max_length=100, validators=[items.models.address_is_valid_monero])),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ItemImage',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='%Y/%m/%d')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='items.Item')),
('thumbnail', models.ImageField(upload_to='%Y/%m/%d')),
],
),
]

@ -0,0 +1,111 @@
from os import path as os_path
from secrets import token_urlsafe
from django.db import models
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from monero.address import address
from PIL import Image, ExifTags
from io import BytesIO
from core.monero import AuctionDaemon
def address_is_valid_monero(value):
try:
address(value)
return True
except ValueError:
raise ValidationError(
_('%(value)s is an invalid Monero address'),
params={'value': value},
)
class Item(models.Model):
owner = models.ForeignKey(User, related_name='owner', on_delete=models.CASCADE)
name = models.CharField(max_length=100)
list_date = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True)
description = models.TextField(max_length=500)
ask_price_xmr = models.FloatField()
available = models.BooleanField(default=True)
payout_address = models.CharField(max_length=100, validators=[address_is_valid_monero])
def __str__(self):
return f"{self.id} - {self.owner} - {self.name}"
class ItemImage(models.Model):
item = models.ForeignKey(Item, related_name='images', on_delete=models.CASCADE)
image = models.ImageField(upload_to='%Y/%m/%d')
thumbnail = models.ImageField(upload_to='%Y/%m/%d')
def save(self, *args, **kwargs):
self.correct_image()
self.copy_to_storage()
super(ItemImage, self).save(*args, **kwargs)
def copy_to_storage(self):
pass
def correct_image(self):
try:
# Open image and set some variables
img = Image.open(self.image)
img_format = img.format
max_size = (800, 800)
thumb_size = (150, 150)
file_ext = os_path.splitext(self.image.name)[1]
random_str = token_urlsafe(12)
img_name = f'{self.item.id}-{random_str}.%s{file_ext}'
img_bytes = BytesIO()
thumb_bytes = BytesIO()
# If image contains exif check for orientation and rotate
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
img_exif = img._getexif()
if img_exif:
if orientation in img_exif:
image_orientation = img_exif[orientation]
if image_orientation == 3:
img = img.rotate(180, expand=True)
if image_orientation == 6:
img = img.rotate(-90, expand=True)
if image_orientation == 8:
img = img.rotate(90, expand=True)
# Store a copy of the image for thumbnail
thumb = img.copy()
# Correct the image size to safe maximums
img.thumbnail(max_size, Image.ANTIALIAS)
img.save(img_bytes, format=img_format, quality=80)
self.image = InMemoryUploadedFile(
img_bytes,
'ImageField',
img_name % 'full',
self.image.file.content_type,
img.size,
self.image.file.charset
)
# Create thumbnail from image
thumb.thumbnail(thumb_size, Image.ANTIALIAS)
thumb.save(thumb_bytes, format=img_format, quality=80)
self.thumbnail = InMemoryUploadedFile(
thumb_bytes,
'ImageField',
img_name % 'thumbnail',
self.image.file.content_type,
img.size,
self.image.file.charset
)
thumb.close()
img.close()
except:
raise Exception('Unable to correct image size')
def __str__(self):
return f"{self.id} - {self.item.name} - {self.id}"

@ -0,0 +1,111 @@
from secrets import token_urlsafe
from django.test.utils import setup_test_environment
from django.test import TestCase, Client
from django.contrib.auth.models import User
from django.core.paginator import Page
from django.urls import reverse
from items.models import Item, ItemImage
class ItemsTestCase(TestCase):
def setUp(self):
self.test_user_username = 'tester'
self.test_user_password = token_urlsafe(32)
self.test_user = User.objects.create_user(
self.test_user_username,
password=self.test_user_password
)
self.test_item = Item.objects.create(
owner=self.test_user,
name='Test Item',
description='Test item',
ask_price_xmr=0.3
)
def login(self):
self.client.login(
username=self.test_user_username,
password=self.test_user_password
)
def logout(self):
self.client.logout()
def test_list_items_should_allow_anonymous(self):
response = self.client.get(reverse('list_items'))
self.assertEqual(response.status_code, 200)
def test_get_item_should_allow_anonymous(self):
response = self.client.get(reverse('get_item', args=[self.test_item.id]))
self.assertEqual(response.status_code, 200)
def test_list_items_returns_page(self):
response = self.client.get(reverse('list_items'))
items = response.context['items']
self.assertTrue(isinstance(items, Page))
def test_create_item_should_require_auth(self):
no_auth_response = self.client.get(reverse('create_item'))
self.login()
auth_response = self.client.get(reverse('create_item'))
self.logout()
self.assertEqual(no_auth_response.status_code, 302)
self.assertTrue(no_auth_response.url.startswith('/accounts/login'))
self.assertEqual(auth_response.status_code, 200)
def test_edit_item_should_require_auth(self):
no_auth_response = self.client.get(reverse('edit_item', args=[self.test_item.id]))
self.login()
auth_response = self.client.get(reverse('edit_item', args=[self.test_item.id]))
self.logout()
self.assertEqual(no_auth_response.status_code, 302)
self.assertTrue(no_auth_response.url.startswith('/accounts/login'))
self.assertEqual(auth_response.status_code, 200)
def test_edit_item_should_require_active_user_is_owner(self):
new_user = User.objects.create_user(
'tester2',
password=token_urlsafe(24)
)
new_item = Item.objects.create(
owner=new_user,
name='Test Item 2',
description='Test item 2',
ask_price_xmr=0.3
)
self.login()
test_item_edit_response = self.client.get(reverse('edit_item', args=[self.test_item.id]))
new_item_edit_response = self.client.get(reverse('edit_item', args=[new_item.id]))
self.logout()
self.assertEqual(test_item_edit_response.status_code, 200)
self.assertEqual(new_item_edit_response.status_code, 302)
new_item.delete()
new_user.delete()
def test_delete_item_should_require_auth(self):
no_auth_response = self.client.get(reverse('delete_item', args=[self.test_item.id]))
self.login()
auth_response = self.client.get(reverse('delete_item', args=[self.test_item.id]))
self.logout()
self.assertEqual(no_auth_response.status_code, 302)
self.assertTrue(no_auth_response.url.startswith('/accounts/login'))
def test_delete_item_should_require_active_user_is_owner(self):
new_user = User.objects.create_user(
'tester3',
password=token_urlsafe(24)
)
new_item = Item.objects.create(
owner=new_user,
name='Test Item 3',
description='Test item 3',
ask_price_xmr=0.3
)
self.login()
test_delete_item_response = self.client.get(reverse('delete_item', args=[self.test_item.id]))
new_delete_item_response = self.client.get(reverse('delete_item', args=[new_item.id]))
self.logout()
self.assertEqual(test_delete_item_response.status_code, 302)
self.assertEqual(new_delete_item_response.status_code, 302)
new_item.delete()
new_user.delete()

@ -0,0 +1,11 @@
from django.urls import path
from items import views
urlpatterns = [
path('', views.list_items, name='list_items'),
path('create/', views.create_item, name='create_item'),
path('<int:item_id>/', views.get_item, name='get_item'),
path('<int:item_id>/edit/', views.edit_item, name='edit_item'),
path('<int:item_id>/delete/', views.delete_item, name='delete_item'),
]

@ -0,0 +1,144 @@
from django.shortcuts import render, HttpResponseRedirect, reverse
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.contrib import messages
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.forms import inlineformset_factory
from items.forms import CreateItemForm
from items.models import Item, ItemImage
from bids.models import ItemBid
from sales.models import ItemSale
def list_items(request):
page_query = request.GET.get('page', 1)
user_query = request.GET.get('user', 0)
item_list = Item.objects.all().order_by('-list_date')
# If the user query string resolves to real user, show their items, otherwise show all
if user_query:
user = User.objects.filter(id=user_query).first()
if user:
item_list = Item.objects.filter(owner=user).order_by('-list_date')
paginator = Paginator(item_list, 20)
try:
items = paginator.page(page_query)
except PageNotAnInteger:
items = paginator.page(1)
except EmptyPage:
items = paginator.page(paginator.num_pages)
context = {
'items': items
}
return render(request, 'items/list_items.html', context)
def get_item(request, item_id):
item = Item.objects.get(id=item_id)
item_images = item.images.all()
item_bids = item.bids.all().order_by('-bid_price_xmr')
context = {
'item': item,
'item_images': item_images,
'item_bids': item_bids
}
return render(request, 'items/get_item.html', context)
@login_required
def create_item(request):
ItemImageFormSet = inlineformset_factory(Item, ItemImage, fields=('image',))
if request.method == 'POST':
form = CreateItemForm(request.POST)
if form.is_valid():
new_item = form.save(commit=False)
new_item.owner = request.user
formset = ItemImageFormSet(request.POST, request.FILES, instance=new_item)
if formset.is_valid():
new_item.save()
formset.save()
return HttpResponseRedirect(reverse('get_item', args=[new_item.id]))
else:
messages.error(request, "Unable to save images.")
for err in formset.errors:
messages.error(request, err)
return HttpResponseRedirect(reverse('create_item'))
else:
form_errors = form.errors.get_json_data()
for err in form_errors:
err_data = form_errors[err][0]
messages.error(request, f'{err}: {err_data["message"]}')
return HttpResponseRedirect(reverse('create_item'))
context = {
'form': CreateItemForm(request.POST or None),
'formset': ItemImageFormSet()
}
return render(request, 'items/create_item.html', context)
@login_required
def edit_item(request, item_id):
item = Item.objects.get(id=item_id)
ItemImageFormSet = inlineformset_factory(Item, ItemImage, fields=('image',))
# Do not allow editing if current user is not the owner
if request.user != item.owner:
messages.error(request, "You can't edit an item you didn't post.")
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
# Do not allow editing if item is not available
if item.available is False:
messages.error(request, "You can't edit an item that is pending sale.")
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
if request.method == 'POST':
form = CreateItemForm(request.POST, instance=item)
if form.is_valid():
saved_item = form.save(commit=False)
formset = ItemImageFormSet(request.POST, request.FILES, instance=saved_item)
if formset.is_valid():
saved_item.save()
formset.save()
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
else:
messages.error(request, "Unable to save images.")
for err in formset.errors:
messages.error(request, err)
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
else:
form_errors = form.errors.get_json_data()
for err in form_errors:
err_data = form_errors[err][0]
messages.error(request, f'{err}: {err_data["message"]}')
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
else:
context = {
'form': CreateItemForm(instance=item),
'formset': ItemImageFormSet(instance=item)
}
return render(request, 'items/edit_item.html', context)
@login_required
def delete_item(request, item_id):
item = Item.objects.get(id=item_id)
# Do not allow deleting if current user is not the owner
if request.user != item.owner:
messages.error(request, "You can't delete an item you didn't post.")
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
# Do not allow deleting if item is not available
if item.available is False:
messages.error(request, "You can't delete an item that is pending sale.")
return HttpResponseRedirect(reverse('get_item', args=[item_id]))
item.delete()
messages.success(request, f"Item #{item_id}, \"{item.name}\", deleted!")
return HttpResponseRedirect(reverse('list_items'))

@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xmrauctions.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

@ -0,0 +1,10 @@
Django==2.2.7
Pillow==6.2.1
django-redis==4.11.0
django-registration==3.0.1
django-storages==1.8.0
huey==2.1.3
monero==0.6.2
psycopg2==2.8.4
pysha3==1.0.2
redis==3.3.11

@ -0,0 +1,5 @@
from django.contrib import admin
from sales.models import ItemSale
admin.site.register(ItemSale)

@ -0,0 +1,5 @@
from django.apps import AppConfig
class SalesConfig(AppConfig):
name = 'sales'

@ -0,0 +1,44 @@
# Generated by Django 2.2.7 on 2019-12-20 05:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('bids', '0001_initial'),
('items', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ItemSale',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('escrow_address', models.CharField(max_length=96)),
('escrow_account_index', models.IntegerField()),
('agreed_price_xmr', models.FloatField()),
('platform_fee_xmr', models.FloatField()),
('expected_payment_xmr', models.FloatField()),
('received_payment_xmr', models.FloatField(default=0.0)),
('escrow_period_days', models.PositiveSmallIntegerField(default=30)),
('buyer_notified', models.BooleanField(default=False)),
('payment_received', models.BooleanField(default=False)),
('seller_notified', models.BooleanField(default=False)),
('payment_refunded', models.BooleanField(default=False)),
('item_shipped', models.BooleanField(default=False)),
('item_received', models.BooleanField(default=False)),
('buyer_disputed', models.BooleanField(default=False)),
('seller_disputed', models.BooleanField(default=False)),
('escrow_complete', models.BooleanField(default=False)),
('seller_paid', models.BooleanField(default=False)),
('platform_paid', models.BooleanField(default=False)),
('sale_finalized', models.BooleanField(default=False)),
('bid', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bids', to='bids.ItemBid')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sales', to='items.Item')),
],
),
]

@ -0,0 +1,32 @@
from django.db import models
from django.contrib.auth.models import User
from django.conf import settings
from items.models import Item
from bids.models import ItemBid
class ItemSale(models.Model):
item = models.ForeignKey(Item, related_name='sales', on_delete=models.CASCADE)
bid = models.ForeignKey(ItemBid, related_name='bids', on_delete=models.CASCADE)
escrow_address = models.CharField(max_length=96)
escrow_account_index = models.IntegerField()
agreed_price_xmr = models.FloatField()
platform_fee_xmr = models.FloatField()
expected_payment_xmr = models.FloatField()
received_payment_xmr = models.FloatField(default=0.0)
escrow_period_days = models.PositiveSmallIntegerField(default=settings.ESCROW_PERIOD_DAYS)
buyer_notified = models.BooleanField(default=False)
payment_received = models.BooleanField(default=False)
seller_notified = models.BooleanField(default=False)
payment_refunded = models.BooleanField(default=False)
item_shipped = models.BooleanField(default=False)
item_received = models.BooleanField(default=False)
buyer_disputed = models.BooleanField(default=False)
seller_disputed = models.BooleanField(default=False)
escrow_complete = models.BooleanField(default=False)
seller_paid = models.BooleanField(default=False)
platform_paid = models.BooleanField(default=False)
sale_finalized = models.BooleanField(default=False)
def __str__(self):
return f"{self.id} - {self.item.name} - {self.bid.bidder} > {self.item.owner}"

@ -0,0 +1,93 @@
from decimal import Decimal
from huey import crontab
from huey.contrib.djhuey import periodic_task
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
from core.monero import AuctionWallet
from sales.models import ItemSale
class EmailTemplate:
def __init__(self, item, role):
context = {
'sale': item,
'site_name': settings.SITE_NAME,
'site_url': settings.SITE_URL,
'sale_path': reverse('get_sale', args=[item.bid.id])
}
subject = render_to_string(
template_name=f'sales/notify/{role}/subject.txt',
context=context,
request=None
)
body = render_to_string(
template_name=f'sales/notify/{role}/body.txt',
context=context,
request=None
)
self.subject = ''.join(subject.splitlines())
self.body = body
@periodic_task(crontab(minute='*/3'))
def notify_buyer_of_pending_sale():
item_sales = ItemSale.objects.filter(buyer_notified=False)
for sale in item_sales:
email_template = EmailTemplate(
item=sale,
role='buyer'
)
sent = send_mail(
email_template.subject,
email_template.body,
settings.EMAIL_FROM,
[sale.bid.bidder.email]
)
if sent == 1:
sale.buyer_notified = True
sale.save()
return True
else:
return False
@periodic_task(crontab(minute='*/2'))
def notify_seller_of_funds_received():
item_sales = ItemSale.objects.filter(seller_notified=False, buyer_notified=True, payment_received=True)
for sale in item_sales:
email_template = EmailTemplate(
item=sale,
role='seller'
)
sent = send_mail(
email_template.subject,
email_template.body,
settings.EMAIL_FROM,
[sale.item.owner.email]
)
if sent == 1:
sale.seller_notified = True
sale.save()
return True
else:
return False
@periodic_task(crontab(minute='*/10'))
def poll_for_buyer_escrow_payments():
aw = AuctionWallet()
item_sales = ItemSale.objects.filter(payment_received=False)
for sale in item_sales:
sale_account = aw.wallet.accounts[sale.escrow_account_index]
sale.received_payment_xmr = sale_account.balance()
if sale_account.balance() >= Decimal(str(sale.expected_payment_xmr)):
sale.payment_received = True
sale.save()
if settings.DEBUG:
print('[+] Sale: #{} - Balance: {} - Payment Received: {}'.format(
sale.id, sale.received_payment_xmr, sale.payment_received
))

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path('<int:bid_id>/', views.get_sale, name='get_sale'),
]

@ -0,0 +1,22 @@
from django.shortcuts import render, HttpResponseRedirect, reverse
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from bids.models import ItemBid
from sales.models import ItemSale
@login_required
def get_sale(request, bid_id):
bid = ItemBid.objects.get(id=bid_id)
sale = ItemSale.objects.get(bid=bid)
# Do not proceed unless current user is a buyer or seller
if request.user != bid.bidder and request.user != sale.item.owner:
messages.error(request, "You can't view a sale you are not involved in.")
return HttpResponseRedirect(reverse('home'))
context = {
'sale': sale
}
return render(request, 'sales/get_sale.html', context)

File diff suppressed because one or more lines are too long

@ -0,0 +1,7 @@
<component lightWeight="true">
<attach event="onpropertychange" onevent="handlePropertychange()" />
<attach event="ondetach" onevent="restore()" />
<attach event="onresize" for="window" onevent="handleResize()" />
<script type="text/javascript">
var rsrc=/url\(["']?(.*?)["']?\)/,positions={top:0,left:0,bottom:1,right:1,center:0.5},doc=element.document;init(); function init(){var b=doc.createElement("div"),a=doc.createElement("img"),c,d;b.style.position="absolute";b.style.zIndex=-1;b.style.top=0;b.style.right=0;b.style.left=0;b.style.bottom=0;b.style.overflow="hidden";a.style.position="absolute";a.style.width=a.style.width="auto";b.appendChild(a);element.insertBefore(b,element.firstChild);d=[element.currentStyle.backgroundPositionX,element.currentStyle.backgroundPositionY];element.bgsExpando=c={wrapper:b,img:a,backgroundSize:element.currentStyle["background-size"], backgroundPositionX:positions[d[0]]||parseFloat(d[0])/100,backgroundPositionY:positions[d[1]]||parseFloat(d[1])/100};"auto"==element.currentStyle.zIndex&&(element.style.zIndex=0);"static"==element.currentStyle.position&&(element.style.position="relative");refreshDisplay(element,c)&&(refreshDimensions(element,c),refreshBackgroundImage(element,c,function(){updateBackground(element,c)}))} function refreshDisplay(b,a){var c=b.currentStyle.display;c!=a.display&&(a.display=c,a.somethingChanged=!0);return"none"!=c}function refreshDimensions(b,a){var c=b.offsetWidth-(parseFloat(b.currentStyle.borderLeftWidth)||0)-(parseFloat(b.currentStyle.borderRightWidth)||0),d=b.offsetHeight-(parseFloat(b.currentStyle.borderTopWidth)||0)-(parseFloat(b.currentStyle.borderBottomWidth)||0);if(c!=a.innerWidth||d!=a.innerHeight)a.innerWidth=c,a.innerHeight=d,a.somethingChanged=!0} function refreshBackgroundImage(b,a,c){var d=a.img,e=(rsrc.exec(b.currentStyle.backgroundImage)||[])[1];if(e&&e!=a.backgroundSrc){a.backgroundSrc=e;a.somethingChanged=!0;d.onload=function(){var b=d.width,e=d.height;1==b&&1==e||(a.imgWidth=b,a.imgHeight=e,a.constrain=!1,c(),d.style.visibility="visible",d.onload=null)};d.style.visibility="hidden";d.src=a.backgroundSrc;if(d.readyState||d.complete)d.src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==",d.src=a.backgroundSrc;a.ignoreNextPropertyChange= !0;b.style.backgroundImage="none"}else c()} function updateBackground(b,a){if(a.somethingChanged){var c=a.img,d=a.innerWidth/a.innerHeight,e=a.imgWidth/a.imgHeight,f=a.constrain;"contain"==a.backgroundSize?e>d?(a.constrain=d="width",e=Math.floor((a.innerHeight-a.innerWidth/e)*a.backgroundPositionY),c.style.top=e+"px",d!=f&&(c.style.width="100%",c.style.height="auto",c.style.left=0)):(a.constrain=d="height",e=Math.floor((a.innerWidth-a.innerHeight*e)*a.backgroundPositionX),c.style.left=e+"px",d!=f&&(c.style.width="auto",c.style.height="100%", c.style.top=0)):"cover"==a.backgroundSize&&(e>d?(a.constrain=d="height",e=Math.floor((a.innerHeight*e-a.innerWidth)*a.backgroundPositionX),c.style.left=-e+"px",d!=f&&(c.style.width="auto",c.style.height="100%",c.style.top=0)):(a.constrain=d="width",e=Math.floor((a.innerWidth/e-a.innerHeight)*a.backgroundPositionY),c.style.top=-e+"px",d!=f&&(c.style.width="100%",c.style.height="auto",c.style.left=0)));a.somethingChanged=!1}} function handlePropertychange(){var b=element.bgsExpando;b.ignoreNextPropertyChange?b.ignoreNextPropertyChange=!1:refreshDisplay(element,b)&&(refreshDimensions(element,b),refreshBackgroundImage(element,b,function(){updateBackground(element,b)}))}function handleResize(){var b=element.bgsExpando;"none"!=b.display&&(refreshDimensions(element,b),updateBackground(element,b))} function restore(){var b=element.bgsExpando;try{element.style.backgroundImage="url('"+b.backgroundSrc+"')",element.removeChild(b.wrapper),element.bgsExpando=null}catch(a){}};
</script>

@ -0,0 +1,8 @@
/*
HTML5 Shiv v3.6.2 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
*/
(function(l,f){function m(){var a=e.elements;return"string"==typeof a?a.split(" "):a}function i(a){var b=n[a[o]];b||(b={},h++,a[o]=h,n[h]=b);return b}function p(a,b,c){b||(b=f);if(g)return b.createElement(a);c||(c=i(b));b=c.cache[a]?c.cache[a].cloneNode():r.test(a)?(c.cache[a]=c.createElem(a)).cloneNode():c.createElem(a);return b.canHaveChildren&&!s.test(a)?c.frag.appendChild(b):b}function t(a,