Merge branch 'master' of https://github.com/Gluejar/regluit
commit
1fe7bf3dc9
|
@ -9,7 +9,7 @@ from tastypie import fields
|
|||
from tastypie.constants import ALL, ALL_WITH_RELATIONS
|
||||
from tastypie.resources import ModelResource, Resource, Bundle
|
||||
from tastypie.utils import trailing_slash
|
||||
from tastypie.authentication import ApiKeyAuthentication
|
||||
from tastypie.authentication import ApiKeyAuthentication, Authentication
|
||||
|
||||
from regluit.core import models
|
||||
|
||||
|
@ -17,6 +17,7 @@ from regluit.core import models
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class UserResource(ModelResource):
|
||||
class Meta:
|
||||
authentication = ApiKeyAuthentication()
|
||||
|
@ -97,6 +98,7 @@ class EditionCoverResource(ModelResource):
|
|||
class WishlistResource(ModelResource):
|
||||
user = fields.ToOneField(UserResource, 'user')
|
||||
works = fields.ToManyField(WorkResource, 'works')
|
||||
|
||||
class Meta:
|
||||
authentication = ApiKeyAuthentication()
|
||||
queryset = models.Wishlist.objects.all()
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
|
@ -15,16 +16,22 @@ class Campaign(models.Model):
|
|||
def __unicode__(self):
|
||||
return u"Campaign for %s" % self.work.title
|
||||
|
||||
def cover_image_small(self):
|
||||
first_isbn = self.work.editions.all()[0].isbn_10
|
||||
return "http://covers.openlibrary.org/b/isbn/%s-S.jpg" % first_isbn
|
||||
|
||||
|
||||
class Work(models.Model):
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
title = models.CharField(max_length=1000)
|
||||
openlibrary_id = models.CharField(max_length=50, null=True)
|
||||
|
||||
@classmethod
|
||||
def get_by_isbn(klass, isbn):
|
||||
for w in Work.objects.filter(Q(editions__isbn_10=isbn) | Q(editions__isbn_13=isbn)):
|
||||
return w
|
||||
return None
|
||||
|
||||
def cover_image_small(self):
|
||||
first_isbn = self.editions.all()[0].isbn_10
|
||||
return "http://covers.openlibrary.org/b/isbn/%s-S.jpg" % first_isbn
|
||||
|
||||
def __unicode__(self):
|
||||
return self.title
|
||||
|
||||
|
@ -62,6 +69,11 @@ class Edition(models.Model):
|
|||
def __unicode__(self):
|
||||
return self.title
|
||||
|
||||
@classmethod
|
||||
def get_by_isbn(klass, isbn):
|
||||
for e in Edition.objects.filter(Q(isbn_10=isbn) | Q(isbn_13=isbn)):
|
||||
return e
|
||||
return None
|
||||
|
||||
class EditionCover(models.Model):
|
||||
openlibrary_id = models.IntegerField()
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
import json
|
||||
import requests
|
||||
|
||||
def gluejar_search(q):
|
||||
"""normalizes results from the google books search suitable for gluejar
|
||||
"""
|
||||
results = []
|
||||
|
||||
for item in googlebooks_search(q)['items']:
|
||||
# TODO: better to think in terms of editions with titles
|
||||
# instead of titles with names?
|
||||
v = item['volumeInfo']
|
||||
r = {'title': v.get('title', ""),
|
||||
'description': v.get('description', ""),
|
||||
'publisher': v.get('publisher', ""),
|
||||
'google_id': item.get('selfLink')}
|
||||
|
||||
# TODO: allow multiple authors
|
||||
if v.has_key('authors') and len(v['authors']) > 0:
|
||||
r['author'] = v['authors'][0]
|
||||
else:
|
||||
r['author'] = ""
|
||||
r['isbn_10'] = None
|
||||
r['isbn_13'] = None
|
||||
|
||||
# pull out isbns
|
||||
for i in v.get('industryIdentifiers', []):
|
||||
if i['type'] == 'ISBN_13':
|
||||
r['isbn_13'] = i['identifier']
|
||||
if i['type'] == 'ISBN_10':
|
||||
r['isbn_10'] = i['identifier']
|
||||
|
||||
# cover image
|
||||
if v.has_key('imageLinks'):
|
||||
r['image'] = v['imageLinks'].get('smallThumbnail', "")
|
||||
else:
|
||||
r['image'] = ""
|
||||
|
||||
results.append(r)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def googlebooks_search(q):
|
||||
# XXX: need to pass IP address of user in from the frontend
|
||||
headers = {'X-Forwarded-For': '69.243.24.29'}
|
||||
r = requests.get('https://www.googleapis.com/books/v1/volumes',
|
||||
params={'q': q}, headers=headers)
|
||||
return json.loads(r.content)
|
|
@ -1,6 +1,6 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from regluit.core import bookloader, models
|
||||
from regluit.core import bookloader, models, search
|
||||
|
||||
class TestBooks(TestCase):
|
||||
|
||||
|
@ -41,3 +41,21 @@ class TestBooks(TestCase):
|
|||
self.assertEqual(models.Work.objects.all().count(), 1)
|
||||
self.assertEqual(models.Subject.objects.all().count(), 18)
|
||||
|
||||
|
||||
class SearchTests(TestCase):
|
||||
|
||||
def test_basic_search(self):
|
||||
results = search.gluejar_search('melville')
|
||||
self.assertEqual(len(results), 10)
|
||||
|
||||
r = results[0]
|
||||
self.assertTrue(r.has_key('title'))
|
||||
self.assertTrue(r.has_key('author'))
|
||||
self.assertTrue(r.has_key('description'))
|
||||
self.assertTrue(r.has_key('image'))
|
||||
self.assertTrue(r.has_key('publisher'))
|
||||
self.assertTrue(r.has_key('isbn_10'))
|
||||
|
||||
def test_googlebooks_search(self):
|
||||
response = search.googlebooks_search('melville')
|
||||
self.assertEqual(len(response['items']), 10)
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
<link type="text/css" rel="stylesheet" href="/static/css/book_list.css" />
|
||||
<script type="text/javascript" src="/static/js/jquery-1.6.3.min.js"></script>
|
||||
<script type="text/javascript" src="/static/js/book-panel.js"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
<div class="book-list row1">
|
||||
<div class="book-thumb">
|
||||
<a href="#"><img src="{{ campaign.cover_image_small }}" alt="Book name" title="book name" /></a>
|
||||
</div>
|
||||
<div class="book-name">
|
||||
<span>
|
||||
{{ campaign.work.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-wishlist">
|
||||
<a href="#">Add to Wishlist</a>
|
||||
</div>
|
||||
<div class="booklist-status">
|
||||
<span class="booklist-status-text">Status: In Progress</span>
|
||||
<span class="booklist-status-img">
|
||||
<img src="/static/images/booklist/icon1.png" title="book list status" alt="book list status" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="unglue-this none">
|
||||
<div class="unglue-this-inner1">
|
||||
<div class="unglue-this-inner2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,28 +1,37 @@
|
|||
<div id="js-leftcol">
|
||||
<div class="jsmodule">
|
||||
<h3 class="jsmod-title"><span>Explore</span></h3>
|
||||
<div id="js-leftcol">
|
||||
<div class="jsmodule">
|
||||
<h3 class="jsmod-title"><span>Explore</span></h3>
|
||||
<div class="jsmod-content">
|
||||
<ul class="menu level1">
|
||||
<li class="first parent">
|
||||
<a href="#"><span>Show me...</span></a>
|
||||
<ul class="menu level2">
|
||||
<li class="first"><a href="#"><span>Recommended</span></a></li>
|
||||
<li><a href="#"><span>Popular</span></a></li>
|
||||
<li><a href="#"><span>Almost unglued</span></a></li>
|
||||
<li><a href="#"><span>Recently unglued</span></a></li>
|
||||
<li><a href="#"><span>Ending Soon</span></a></li>
|
||||
<li class="last"><a href="#"><span>Just Listed</span></a></li>
|
||||
<ul class="menu level1">
|
||||
|
||||
<li>
|
||||
<div class="js-search" style="margin-top: 10px; margin-bottom: 10px;">
|
||||
<form action="{% url search %}" method="get"><input type="text" placeholder="Search for a book..." size="20" class="inputbox" id="ssearchword" name="q" value="{{ q }}">
|
||||
</form>
|
||||
<input type="button" onclick="this.form.searchword.focus();" class="button" value="Search">
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="first parent">
|
||||
<a href="#"><span>Show me...</span></a>
|
||||
<ul class="menu level2">
|
||||
<li class="first"><a href="#"><span>Recommended</span></a></li>
|
||||
<li><a href="#"><span>Popular</span></a></li>
|
||||
<li><a href="#"><span>Almost unglued</span></a></li>
|
||||
<li><a href="#"><span>Recently unglued</span></a></li>
|
||||
<li><a href="#"><span>Ending Soon</span></a></li>
|
||||
<li class="last"><a href="#"><span>Just Listed</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="parent">
|
||||
<a href="#"><span>Show me...</span></a>
|
||||
<ul class="menu level2">
|
||||
<li class="first"><a href="#"><span>Recommended</span></a></li>
|
||||
<li><a href="#"><span>Popular</span></a></li>
|
||||
<li><a href="#"><span>Almost unglued</span></a></li>
|
||||
<li><a href="#"><span>Recently unglued</span></a></li>
|
||||
<li><a href="#"><span>Ending Soon</span></a></li>
|
||||
<li class="last"><a href="#"><span>Just Listed</span></a></li>
|
||||
<a href="#"><span>Show me...</span></a>
|
||||
<ul class="menu level2">
|
||||
<li class="first"><a href="#"><span>Recommended</span></a></li>
|
||||
<li><a href="#"><span>Popular</span></a></li>
|
||||
<li><a href="#"><span>Almost unglued</span></a></li>
|
||||
<li><a href="#"><span>Recently unglued</span></a></li>
|
||||
<li><a href="#"><span>Ending Soon</span></a></li>
|
||||
<li class="last"><a href="#"><span>Just Listed</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -11,8 +11,7 @@
|
|||
<div class="js-search">
|
||||
<h2>What book would you give to the world?</h2>
|
||||
<div class="js-search-inner">
|
||||
<form action="">
|
||||
<input type="text" onfocus="if (this.value=='Search for a book...') this.value='';" onblur="if (this.value=='') this.value='Search for a book...';" value="Search for a book..." size="30" class="inputbox" maxlength="200" id="ssearchword" name="searchword">
|
||||
<form action="{% url search %}" method="get"> <input type="text" onfocus="if (this.value=='Search for a book...') this.value='';" onblur="if (this.value=='') this.value='Search for a book...';" value="Search for a book..." size="30" class="inputbox" maxlength="200" id="ssearchword" name="q">
|
||||
<input type="button" onclick="this.form.searchword.focus();" class="button" value="Search">
|
||||
</form>
|
||||
</div>
|
||||
|
@ -22,4 +21,4 @@
|
|||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$(".add-wishlist").each(function (index, element) {
|
||||
$(element).click(function() {
|
||||
var span = $(element).find("span");
|
||||
var isbn = span.attr('id')
|
||||
if (!isbn) return;
|
||||
$.post('/wishlist/', {'isbn': isbn}, function(data) {
|
||||
span.fadeOut();
|
||||
var newSpan = $("<span>On Your Wishlist!</span>").hide();
|
||||
span.replaceWith(newSpan);
|
||||
newSpan.fadeIn();
|
||||
newSpan.removeAttr("id");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Search Results{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="main-container">
|
||||
<div class="js-main">
|
||||
|
||||
{% include "explore.html" %}
|
||||
|
||||
<div id="js-maincol-fr">
|
||||
<div class="js-maincol-inner">
|
||||
<div class="content-block">
|
||||
<div class="content-block-heading">
|
||||
<h2 class="content-heading">Search Results</span></h2>
|
||||
<ul class="book-list-view">
|
||||
<li>View As:</li>
|
||||
<li class="view-list">
|
||||
<a href="#view-list">
|
||||
<img src="/static/images/booklist/view-list.png" align="view list" title="view list" height="21" width="24" />
|
||||
</a>
|
||||
</li>
|
||||
<li class="view-list">
|
||||
<a href="#view-icon">
|
||||
<img src="/static/images/booklist/view-icon.png" align="view icon" title="view icon" height="22" width="22" />
|
||||
</a>
|
||||
</li>
|
||||
<li class="view-list">
|
||||
<a href="#view-icon-small">
|
||||
<img src="/static/images/booklist/view-small-icon.png" align="view icon small" title="view icon small" height="22" width="22" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="content-block-content">
|
||||
{% for result in results %}
|
||||
<div class="book-list row1">
|
||||
<div class="book-thumb">
|
||||
<a href="#"><img src="{{ result.image }}" alt="{{ result.title }}" title="{{ result.title }}" /></a>
|
||||
</div>
|
||||
<div class="book-name">
|
||||
<span>
|
||||
{{ result.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-wishlist">
|
||||
{% if result.on_wishlist %}
|
||||
<span>On Your Wishlist!</span>
|
||||
{% else %}
|
||||
<span id="{{ result.isbn_10 }}">Add to Wishlist</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="booklist-status">
|
||||
<span class="booklist-status-text">Status: In Progress</span>
|
||||
<span class="booklist-status-img">
|
||||
<img src="/static/images/booklist/icon1.png" title="book list status" alt="book list status" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="unglue-this none">
|
||||
<div class="unglue-this-inner1">
|
||||
<div class="unglue-this-inner2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -2,6 +2,23 @@
|
|||
|
||||
{% block title %} — {{ supporter.username }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$(".remove-wishlist").each(function (index, element) {
|
||||
$(element).click(function() {
|
||||
var span = $(element).find("span");
|
||||
var work_id = span.attr('id')
|
||||
$.post('/wishlist/', {'remove_work_id': work_id}, function(data) {
|
||||
var book = $(element).parent();
|
||||
book.fadeOut();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="main-container">
|
||||
<div class="js-main">
|
||||
|
@ -10,20 +27,26 @@
|
|||
|
||||
<div id="js-maincol-fr">
|
||||
<div class="js-maincol-inner">
|
||||
<div class="content-block">
|
||||
<div class="content-block-heading">
|
||||
<h2 class="content-heading">Wishlist for {{ supporter.username }}</h2>
|
||||
<ul class="book-list-view">
|
||||
<li>View As:</li>
|
||||
<div class="content-block">
|
||||
<div class="content-block-heading">
|
||||
<h2 class="content-heading">
|
||||
{% ifequal supporter request.user %}
|
||||
Your Wishlist
|
||||
{% else %}
|
||||
{{ supporter.username }} Wishlist
|
||||
{% endifequal %}
|
||||
</h2>
|
||||
<ul class="book-list-view">
|
||||
<li>View As:</li>
|
||||
<li class="view-list">
|
||||
<a href="#view-list">
|
||||
<img src="/static/images/booklist/view-list.png" align="view list" title="view list" height="21" width="24" />
|
||||
</a>
|
||||
<a href="#view-list">
|
||||
<img src="/static/images/booklist/view-list.png" align="view list" title="view list" height="21" width="24" />
|
||||
</a>
|
||||
</li>
|
||||
<li class="view-list">
|
||||
<a href="#view-icon">
|
||||
<img src="/static/images/booklist/view-icon.png" align="view icon" title="view icon" height="22" width="22" />
|
||||
</a>
|
||||
<a href="#view-icon">
|
||||
<img src="/static/images/booklist/view-icon.png" align="view icon" title="view icon" height="22" width="22" />
|
||||
</a>
|
||||
</li>
|
||||
<li class="view-list">
|
||||
<a href="#view-icon-small">
|
||||
|
@ -33,12 +56,35 @@
|
|||
</ul>
|
||||
</div>
|
||||
<div class="content-block-content">
|
||||
{% for campaign in campaigns %}
|
||||
{% include "book_list.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% for work in wishlist.works.all %}
|
||||
<div class="book-list row1">
|
||||
<div class="book-thumb">
|
||||
<a href="#"><img src="{{ work.cover_image_small }}" alt="Book name" title="book name" /></a>
|
||||
</div>
|
||||
<div class="book-name">
|
||||
<span>
|
||||
{{ work.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="remove-wishlist">
|
||||
<span id="{{ work.id }}">Remove from Wishlist</span>
|
||||
</div>
|
||||
<div class="booklist-status">
|
||||
<span class="booklist-status-text">Status: In Progress</span>
|
||||
<span class="booklist-status-img">
|
||||
<img src="/static/images/booklist/icon1.png" title="book list status" alt="book list status" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="unglue-this none">
|
||||
<div class="unglue-this-inner1">
|
||||
<div class="unglue-this-inner2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
from django.conf.urls.defaults import *
|
||||
from django.views.generic.simple import direct_to_template
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
urlpatterns = patterns(
|
||||
"regluit.frontend.views",
|
||||
url(r"^$", "home", name="home"),
|
||||
url(r"^supporter/(?P<supporter_username>.+)/$", "supporter", name="supporter"),
|
||||
url(r"^privacy$", "textpage", {'page': 'privacy'}, name="privacy"),
|
||||
url(r"^search/$", "search", name="search"),
|
||||
url(r"^privacy/$", TemplateView.as_view(template_name="privacy.html"),
|
||||
name="privacy"),
|
||||
url(r"^wishlist/$", "wishlist", name="wishlist"),
|
||||
)
|
||||
|
|
|
@ -2,30 +2,70 @@ from django.template import RequestContext
|
|||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render_to_response, get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render, render_to_response, get_object_or_404
|
||||
|
||||
from regluit.core import models
|
||||
from regluit.core import models, bookloader
|
||||
from regluit.core.search import gluejar_search
|
||||
|
||||
def home(request):
|
||||
# if the user is logged in send them to their supporter page
|
||||
if request.user.is_authenticated():
|
||||
return HttpResponseRedirect(reverse('supporter',
|
||||
args=[request.user.username]))
|
||||
return render_to_response('home.html',
|
||||
{},
|
||||
context_instance=RequestContext(request)
|
||||
)
|
||||
return render(request, 'home.html')
|
||||
|
||||
def supporter(request, supporter_username):
|
||||
supporter = get_object_or_404(User, username=supporter_username)
|
||||
campaigns = models.Campaign.objects.all()
|
||||
return render_to_response('supporter.html',
|
||||
{"supporter": supporter, "campaigns": campaigns},
|
||||
context_instance=RequestContext(request)
|
||||
)
|
||||
wishlist = supporter.wishlist
|
||||
context = {
|
||||
"supporter": supporter,
|
||||
"wishlist": wishlist,
|
||||
}
|
||||
return render(request, 'supporter.html', context)
|
||||
|
||||
def textpage(request, page):
|
||||
return render_to_response(page + '.html',
|
||||
{},
|
||||
context_instance=RequestContext(request)
|
||||
)
|
||||
def search(request):
|
||||
q = request.GET.get('q', None)
|
||||
results = gluejar_search(q)
|
||||
|
||||
# flag search result as on wishlist
|
||||
# TODO: make this better and faster
|
||||
if request.user:
|
||||
for result in results:
|
||||
if not result.has_key('isbn_10'):
|
||||
continue
|
||||
work = models.Work.get_by_isbn(result['isbn_10'])
|
||||
if work and work in request.user.wishlist.works.all():
|
||||
result['on_wishlist'] = True
|
||||
else:
|
||||
result['on_wishlist'] = False
|
||||
|
||||
context = {
|
||||
"q": q,
|
||||
"results": results,
|
||||
}
|
||||
return render(request, 'search.html', context)
|
||||
|
||||
# TODO: perhaps this functionality belongs in the API?
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
@login_required
|
||||
def wishlist(request):
|
||||
isbn = request.POST.get('isbn', None)
|
||||
remove_work_id = request.POST.get('remove_work_id', None)
|
||||
if isbn:
|
||||
edition = models.Edition.get_by_isbn(isbn)
|
||||
if not edition:
|
||||
print "loading book"
|
||||
edition = bookloader.add_book(isbn)
|
||||
if edition:
|
||||
print "adding edition"
|
||||
request.user.wishlist.works.add(edition.work)
|
||||
# TODO: redirect to work page, when it exists
|
||||
return HttpResponseRedirect('/')
|
||||
elif remove_work_id:
|
||||
work = models.Work.objects.get(id=int(remove_work_id))
|
||||
request.user.wishlist.works.remove(work)
|
||||
# TODO: where to redirect?
|
||||
return HttpResponseRedirect('/')
|
||||
|
|
|
@ -2,13 +2,21 @@ from regluit.core.models import Campaign, Wishlist
|
|||
from regluit.payment.models import Transaction, Receiver
|
||||
from django.contrib.auth.models import User
|
||||
from regluit.payment.parameters import *
|
||||
from regluit.payment.paypal import Pay, IPN, IPN_TYPE_PAYMENT
|
||||
from regluit.payment.paypal import Pay, IPN, IPN_TYPE_PAYMENT, IPN_TYPE_PREAPPROVAL, Preapproval
|
||||
import uuid
|
||||
import traceback
|
||||
|
||||
class PaymentManager( object ):
|
||||
|
||||
'''
|
||||
processIPN
|
||||
|
||||
Turns a request from Paypal into an IPN, and extracts info. We support 2 types of IPNs:
|
||||
|
||||
1) Payment - Used for instant payments and to execute pre-approved payments
|
||||
2) Preapproval - Used for comfirmation of a preapproval
|
||||
|
||||
'''
|
||||
def processIPN(self, request):
|
||||
|
||||
try:
|
||||
|
@ -16,11 +24,16 @@ class PaymentManager( object ):
|
|||
|
||||
if ipn.success():
|
||||
print "Valid IPN"
|
||||
|
||||
t = Transaction.objects.get(reference=ipn.key)
|
||||
|
||||
|
||||
if ipn.transaction_type == IPN_TYPE_PAYMENT:
|
||||
|
||||
if ipn.preapproval_key:
|
||||
key = ipn.preapproval_key
|
||||
else:
|
||||
key = ipn.key
|
||||
|
||||
t = Transaction.objects.get(reference=key)
|
||||
t.status = ipn.status
|
||||
|
||||
for item in ipn.transactions:
|
||||
|
@ -29,16 +42,36 @@ class PaymentManager( object ):
|
|||
r.status = item['status_for_sender_txn']
|
||||
r.save()
|
||||
|
||||
t.save()
|
||||
|
||||
elif ipn.transaction_type == IPN_TYPE_PREAPPROVAL:
|
||||
|
||||
t = Transaction.objects.get(reference=ipn.preapproval_key)
|
||||
t.status = ipn.status
|
||||
t.save()
|
||||
|
||||
else:
|
||||
print "IPN: Unknown Transaction Type: " + ipn.transaction_type
|
||||
|
||||
t.save()
|
||||
|
||||
else:
|
||||
print ipn.error
|
||||
|
||||
except:
|
||||
traceback.print_exc()
|
||||
|
||||
'''
|
||||
query_campaign
|
||||
|
||||
Returns either an amount or list of transactions for a campaign
|
||||
|
||||
summary: if true, return a float of the total, if false, return a list of transactions
|
||||
pledged: include amounts pledged
|
||||
authorized: include amounts pre-authorized
|
||||
|
||||
return value: either a float summary or a list of transactions
|
||||
|
||||
'''
|
||||
def query_campaign(self, campaign, summary=False, pledged=True, authorized=True):
|
||||
|
||||
if pledged:
|
||||
|
@ -51,7 +84,7 @@ class PaymentManager( object ):
|
|||
if authorized:
|
||||
authorized_list = Transaction.objects.filter(campaign=campaign,
|
||||
type=PAYMENT_TYPE_AUTHORIZATION,
|
||||
status="PENDING")
|
||||
status="ACTIVE")
|
||||
else:
|
||||
authorized_list = []
|
||||
|
||||
|
@ -71,10 +104,131 @@ class PaymentManager( object ):
|
|||
else:
|
||||
return pledged_list | authorized_list
|
||||
|
||||
'''
|
||||
execute_campaign
|
||||
|
||||
attempts to execute all pending transactions for a campaign.
|
||||
|
||||
return value: returns a list of transactions with the status of each receiver/transaction updated
|
||||
|
||||
'''
|
||||
def execute_campaign(self, campaign):
|
||||
|
||||
transactions = Transaction.objects.filter(campaign=campaign, status="ACTIVE")
|
||||
|
||||
for t in transactions:
|
||||
|
||||
receiver_list = [{'email':'jakace_1309677337_biz@gmail.com', 'amount':t.amount * 0.80},
|
||||
{'email':'boogus@gmail.com', 'amount':t.amount * 0.20}]
|
||||
|
||||
self.execute_transaction(t, receiver_list)
|
||||
|
||||
return transactions
|
||||
|
||||
'''
|
||||
execute_transaction
|
||||
|
||||
executes a single pending transaction.
|
||||
|
||||
transaction: the transaction object to execute
|
||||
receiver_list: a list of receivers for the transaction, in this format:
|
||||
|
||||
[
|
||||
{'email':'email-1', 'amount':amount1},
|
||||
{'email':'email-2', 'amount':amount2}
|
||||
]
|
||||
|
||||
return value: a bool indicating the success or failure of the process. Please check the transaction status
|
||||
after the IPN has completed for full information
|
||||
|
||||
'''
|
||||
def execute_transaction(self, transaction, receiver_list):
|
||||
|
||||
for r in receiver_list:
|
||||
receiver = Receiver.objects.create(email=r['email'], amount=r['amount'], currency=transaction.currency, status="ACTIVE", transaction=transaction)
|
||||
|
||||
p = Pay(transaction, receiver_list)
|
||||
transaction.status = p.status()
|
||||
|
||||
if p.status() == 'COMPLETED':
|
||||
print "Execute Success"
|
||||
return True
|
||||
|
||||
else:
|
||||
transaction.error = p.error()
|
||||
print "Execute Error: " + p.error()
|
||||
return False
|
||||
|
||||
'''
|
||||
authorize
|
||||
|
||||
authorizes a set amount of money to be collected at a later date
|
||||
|
||||
currency: a 3-letter paypal currency code, i.e. USD
|
||||
target: a defined target type, i.e. TARGET_TYPE_CAMPAIGN, TARGET_TYPE_LIST, TARGET_TYPE_NONE
|
||||
amount: the amount to authorize
|
||||
campaign: optional campaign object(to be set with TARGET_TYPE_CAMPAIGN)
|
||||
list: optional list object(to be set with TARGET_TYPE_LIST)
|
||||
user: optional user object
|
||||
|
||||
return value: a tuple of the new transaction object and a re-direct url. If the process fails,
|
||||
the redirect url will be None
|
||||
|
||||
'''
|
||||
def authorize(self, currency, target, amount, campaign=None, list=None, user=None):
|
||||
|
||||
t = Transaction.objects.create(amount=amount,
|
||||
type=PAYMENT_TYPE_AUTHORIZATION,
|
||||
target=target,
|
||||
currency=currency,
|
||||
secret = str(uuid.uuid1()),
|
||||
status='NONE',
|
||||
campaign=campaign,
|
||||
list=list,
|
||||
user=user
|
||||
)
|
||||
|
||||
p = Preapproval(t, amount)
|
||||
|
||||
if p.status() == 'Success':
|
||||
t.status = 'CREATED'
|
||||
t.reference = p.paykey()
|
||||
t.save()
|
||||
print "Authorize Success: " + p.next_url()
|
||||
return t, p.next_url()
|
||||
|
||||
else:
|
||||
t.status = 'ERROR'
|
||||
t.error = p.error()
|
||||
t.save()
|
||||
print "Authorize Error: " + p.error()
|
||||
return t, None
|
||||
|
||||
|
||||
'''
|
||||
pledge
|
||||
|
||||
Performs an instant payment
|
||||
|
||||
currency: a 3-letter paypal currency code, i.e. USD
|
||||
target: a defined target type, i.e. TARGET_TYPE_CAMPAIGN, TARGET_TYPE_LIST, TARGET_TYPE_NONE
|
||||
receiver_list: a list of receivers for the transaction, in this format:
|
||||
|
||||
[
|
||||
{'email':'email-1', 'amount':amount1},
|
||||
{'email':'email-2', 'amount':amount2}
|
||||
]
|
||||
|
||||
campaign: optional campaign object(to be set with TARGET_TYPE_CAMPAIGN)
|
||||
list: optional list object(to be set with TARGET_TYPE_LIST)
|
||||
user: optional user object
|
||||
|
||||
return value: a tuple of the new transaction object and a re-direct url. If the process fails,
|
||||
the redirect url will be None
|
||||
|
||||
'''
|
||||
def pledge(self, currency, target, receiver_list, campaign=None, list=None, user=None):
|
||||
|
||||
self.currency = currency
|
||||
amount = 0.0
|
||||
for r in receiver_list:
|
||||
amount += r['amount']
|
||||
|
@ -82,7 +236,7 @@ class PaymentManager( object ):
|
|||
t = Transaction.objects.create(amount=amount,
|
||||
type=PAYMENT_TYPE_INSTANT,
|
||||
target=target,
|
||||
currency=self.currency,
|
||||
currency=currency,
|
||||
secret = str(uuid.uuid1()),
|
||||
status='NONE',
|
||||
campaign=campaign,
|
||||
|
@ -103,9 +257,9 @@ class PaymentManager( object ):
|
|||
return t, p.next_url()
|
||||
|
||||
else:
|
||||
t.reference = p.error()
|
||||
t.error = p.error()
|
||||
t.save()
|
||||
print "Pledge Error: " + p.error()
|
||||
print "Pledge Status: " + p.status() + "Error: " + p.error()
|
||||
return t, None
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ class Transaction(models.Model):
|
|||
secret = models.CharField(max_length=64, null=True)
|
||||
reference = models.CharField(max_length=128, null=True)
|
||||
receipt = models.CharField(max_length=256, null=True)
|
||||
error = models.CharField(max_length=256, null=True)
|
||||
date_created = models.DateTimeField(auto_now_add=True)
|
||||
date_modified = models.DateTimeField(auto_now=True)
|
||||
date_payment = models.DateTimeField(null=True)
|
||||
|
@ -21,6 +22,9 @@ class Transaction(models.Model):
|
|||
campaign = models.ForeignKey(Campaign, null=True)
|
||||
list = models.ForeignKey(Wishlist, null=True)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"-- Transaction:\n \tstatus: %s\n \t amount: %s\n \treference: %s\n \terror: %s\n" % (self.status, str(self.amount), self.reference, self.error)
|
||||
|
||||
class Receiver(models.Model):
|
||||
|
||||
email = models.CharField(max_length=64)
|
||||
|
|
|
@ -28,7 +28,7 @@ import decimal
|
|||
# transaction_type constants
|
||||
IPN_TYPE_PAYMENT = 'Adaptive Payment PAY'
|
||||
IPN_TYPE_ADJUSTMENT = 'Adjustment'
|
||||
IPN_TYPE_PREAPPROVAL = 'Adaptive Payment Preapproval'
|
||||
IPN_TYPE_PREAPPROVAL = 'Adaptive Payment PREAPPROVAL'
|
||||
|
||||
#status constants
|
||||
IPN_STATUS_CREATED = 'CREATED'
|
||||
|
@ -89,16 +89,17 @@ class Pay( object ):
|
|||
print "Cancel URL: " + cancel_url
|
||||
|
||||
data = {
|
||||
'actionType': 'PAY',
|
||||
'receiverList': { 'receiver': receiver_list },
|
||||
'currencyCode': transaction.currency,
|
||||
'returnUrl': return_url,
|
||||
'cancelUrl': cancel_url,
|
||||
'requestEnvelope': { 'errorLanguage': 'en_US' },
|
||||
'ipnNotificationUrl': BASE_URL + 'paypalipn'
|
||||
}
|
||||
|
||||
data['actionType'] = 'PAY'
|
||||
data['receiverList'] = { 'receiver': receiver_list }
|
||||
|
||||
data['ipnNotificationUrl'] = BASE_URL + 'paypalipn'
|
||||
|
||||
if transaction.reference:
|
||||
data['preapprovalKey'] = transaction.reference
|
||||
|
||||
self.raw_request = json.dumps(data)
|
||||
|
||||
|
@ -131,6 +132,66 @@ class Pay( object ):
|
|||
return '%s?cmd=_ap-payment&paykey=%s' % ( PAYPAL_PAYMENT_HOST, self.response['payKey'] )
|
||||
|
||||
|
||||
class Preapproval( object ):
|
||||
def __init__( self, transaction, amount ):
|
||||
|
||||
headers = {
|
||||
'X-PAYPAL-SECURITY-USERID':PAYPAL_USERNAME,
|
||||
'X-PAYPAL-SECURITY-PASSWORD':PAYPAL_PASSWORD,
|
||||
'X-PAYPAL-SECURITY-SIGNATURE':PAYPAL_SIGNATURE,
|
||||
'X-PAYPAL-APPLICATION-ID':PAYPAL_APPID,
|
||||
'X-PAYPAL-REQUEST-DATA-FORMAT':'JSON',
|
||||
'X-PAYPAL-RESPONSE-DATA-FORMAT':'JSON',
|
||||
}
|
||||
|
||||
return_url = BASE_URL + COMPLETE_URL
|
||||
cancel_url = BASE_URL + CANCEL_URL
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
expiry = now + datetime.timedelta( days=PREAPPROVAL_PERIOD )
|
||||
|
||||
|
||||
data = {
|
||||
'endingDate': expiry.isoformat(),
|
||||
'startingDate': now.isoformat(),
|
||||
'maxTotalAmountOfAllPayments': '%.2f' % transaction.amount,
|
||||
'currencyCode': transaction.currency,
|
||||
'returnUrl': return_url,
|
||||
'cancelUrl': cancel_url,
|
||||
'requestEnvelope': { 'errorLanguage': 'en_US' },
|
||||
'ipnNotificationUrl': BASE_URL + 'paypalipn'
|
||||
}
|
||||
|
||||
self.raw_request = json.dumps(data)
|
||||
self.raw_response = url_request(PAYPAL_ENDPOINT, "/AdaptivePayments/Preapproval", data=self.raw_request, headers=headers ).content()
|
||||
print "paypal PREAPPROVAL response was: %s" % self.raw_response
|
||||
self.response = json.loads( self.raw_response )
|
||||
print self.response
|
||||
|
||||
def paykey( self ):
|
||||
if self.response.has_key( 'preapprovalKey' ):
|
||||
return self.response['preapprovalKey']
|
||||
else:
|
||||
return None
|
||||
|
||||
def next_url( self ):
|
||||
return '%s?cmd=_ap-preapproval&preapprovalkey=%s' % ( PAYPAL_PAYMENT_HOST, self.response['preapprovalKey'] )
|
||||
|
||||
def error( self ):
|
||||
if self.response.has_key('error'):
|
||||
error = self.response['error']
|
||||
print error
|
||||
return error[0]['message']
|
||||
else:
|
||||
return None
|
||||
|
||||
def status( self ):
|
||||
if self.response.has_key( 'responseEnvelope' ) and self.response['responseEnvelope'].has_key( 'ack' ):
|
||||
return self.response['responseEnvelope']['ack']
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class IPN( object ):
|
||||
|
||||
def __init__( self, request ):
|
||||
|
@ -157,8 +218,8 @@ class IPN( object ):
|
|||
return
|
||||
|
||||
# check payment status
|
||||
if request.POST['status'] != 'COMPLETED':
|
||||
self.error = 'PayPal status was "%s"' % request.get('status')
|
||||
if request.POST['status'] != 'COMPLETED' and request.POST['status'] != 'ACTIVE':
|
||||
self.error = 'PayPal status was "%s"' % request.POST['status']
|
||||
return
|
||||
|
||||
# Process the details
|
||||
|
@ -166,6 +227,7 @@ class IPN( object ):
|
|||
self.sender_email = request.POST.get('sender_email', None)
|
||||
self.action_type = request.POST.get('action_type', None)
|
||||
self.key = request.POST.get('pay_key', None)
|
||||
self.preapproval_key = request.POST.get('preapproval_key', None)
|
||||
self.transaction_type = request.POST.get('transaction_type', None)
|
||||
|
||||
self.process_transactions(request)
|
||||
|
|
|
@ -3,6 +3,8 @@ from django.conf.urls.defaults import *
|
|||
urlpatterns = patterns(
|
||||
"regluit.payment.views",
|
||||
url(r"^testpledge", "testPledge"),
|
||||
url(r"^testauthorize", "testAuthorize"),
|
||||
url(r"^testexecute", "testExecute"),
|
||||
url(r"^querycampaign", "queryCampaign"),
|
||||
url(r"^paypalipn", "paypalIPN")
|
||||
)
|
||||
|
|
|
@ -8,6 +8,11 @@ from django.http import HttpResponse, HttpRequest, HttpResponseRedirect
|
|||
from django.views.decorators.csrf import csrf_exempt
|
||||
import traceback
|
||||
|
||||
'''
|
||||
http://BASE/querycampaign?id=2
|
||||
|
||||
Example that returns a summary total for a campaign
|
||||
'''
|
||||
def queryCampaign(request):
|
||||
|
||||
id = request.GET['id']
|
||||
|
@ -20,7 +25,92 @@ def queryCampaign(request):
|
|||
total = p.query_campaign(campaign, summary=True)
|
||||
|
||||
return HttpResponse(str(total))
|
||||
|
||||
'''
|
||||
http://BASE/testexecute?campaign=2
|
||||
|
||||
Example that executes a set of transactions that are pre-approved
|
||||
'''
|
||||
def testExecute(request):
|
||||
|
||||
p = PaymentManager()
|
||||
|
||||
if 'campaign' in request.GET.keys():
|
||||
campaign_id = request.GET['campaign']
|
||||
campaign = Campaign.objects.get(id=int(campaign_id))
|
||||
else:
|
||||
campaign = None
|
||||
|
||||
output = ''
|
||||
|
||||
if campaign:
|
||||
result = p.execute_campaign(campaign)
|
||||
|
||||
for t in result:
|
||||
output += str(t)
|
||||
print str(t)
|
||||
|
||||
else:
|
||||
transactions = Transaction.objects.filter(status='ACTIVE')
|
||||
|
||||
for t in transactions:
|
||||
|
||||
# Note, set this to 1-5 different receivers with absolute amounts for each
|
||||
receiver_list = [{'email':'jakace_1309677337_biz@gmail.com', 'amount':t.amount * 0.80},
|
||||
{'email':'boogus@gmail.com', 'amount':t.amount * 0.20}]
|
||||
|
||||
p.execute_transaction(t, receiver_list)
|
||||
output += str(t)
|
||||
print str(t)
|
||||
|
||||
return HttpResponse(output)
|
||||
|
||||
|
||||
'''
|
||||
http://BASE/testauthorize?amount=20
|
||||
|
||||
Example that initiates a pre-approval for a set amount
|
||||
'''
|
||||
def testAuthorize(request):
|
||||
|
||||
p = PaymentManager()
|
||||
|
||||
if 'campaign' in request.GET.keys():
|
||||
campaign_id = request.GET['campaign']
|
||||
else:
|
||||
campaign_id = None
|
||||
|
||||
if 'amount' in request.GET.keys():
|
||||
amount = float(request.GET['amount'])
|
||||
else:
|
||||
return HttpResponse("Error, no amount in request")
|
||||
|
||||
|
||||
# Note, set this to 1-5 different receivers with absolute amounts for each
|
||||
receiver_list = [{'email':'jakace_1309677337_biz@gmail.com', 'amount':20.00},
|
||||
{'email':'boogus@gmail.com', 'amount':10.00}]
|
||||
|
||||
if campaign_id:
|
||||
campaign = Campaign.objects.get(id=int(campaign_id))
|
||||
t, url = p.authorize('USD', TARGET_TYPE_CAMPAIGN, amount, campaign=campaign, list=None, user=None)
|
||||
|
||||
else:
|
||||
t, url = p.authorize('USD', TARGET_TYPE_NONE, amount, campaign=None, list=None, user=None)
|
||||
|
||||
if url:
|
||||
print "testAuthorize: " + url
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
else:
|
||||
response = t.reference
|
||||
print "testAuthorize: Error " + str(t.reference)
|
||||
return HttpResponse(response)
|
||||
|
||||
'''
|
||||
http://BASE/testpledge?campaign=2
|
||||
|
||||
Example that initiates an instant payment for a campaign
|
||||
'''
|
||||
def testPledge(request):
|
||||
|
||||
p = PaymentManager()
|
||||
|
|
|
@ -4,7 +4,7 @@ DEBUG = True
|
|||
TEMPLATE_DEBUG = DEBUG
|
||||
|
||||
ADMINS = (
|
||||
# ('Your Name', 'your_email@domain.com'),
|
||||
('Ed Summers', 'ehs@pobox.com'),
|
||||
)
|
||||
|
||||
MANAGERS = ADMINS
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
div.book-list{
|
||||
clear:both;
|
||||
display:block;
|
||||
vertical-align: middle;
|
||||
height:43px;
|
||||
line-height:43px;
|
||||
margin:0 5px 0px 0;
|
||||
padding:7px 0;
|
||||
overflow:hidden;
|
||||
clear:both;
|
||||
display:block;
|
||||
vertical-align: middle;
|
||||
height:43px;
|
||||
line-height:43px;
|
||||
margin:0 5px 0px 0;
|
||||
padding:7px 0;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
div.book-list.row1{ background:#f6f9f9;}
|
||||
|
@ -14,15 +14,16 @@ div.book-list.row2{ background:#fff;}
|
|||
|
||||
div.book-list div.book-thumb,
|
||||
div.book-list div.book-name,
|
||||
div.book-list div.add-wishlist,
|
||||
div.book-list div.booklist-status,
|
||||
div.book-list div.unglue-this{ float: left;}
|
||||
div.book-list div.add-wishlist,
|
||||
div.book-list div.remove-wishlist,
|
||||
div.book-list div.booklist-status,
|
||||
div.book-list div.unglue-this{ float: left;}
|
||||
|
||||
div.book-list div.book-thumb{ margin-right:5px;}
|
||||
div.book-list div.book-name{ width:260px; margin-right:10px; background:url(../images/booklist/booklist-vline.png) right center no-repeat;}
|
||||
div.book-list div.book-name span{ display:block; line-height:normal; height:43px; line-height:43px;}
|
||||
div.book-list div.add-wishlist{ margin-right:10px; padding-right:10px;background:url(../images/booklist/booklist-vline.png) right center no-repeat;}
|
||||
div.book-list div.add-wishlist a{ font-weight:normal; color:#3d4e53; text-transform: none; background:url(../images/booklist/add-wishlist.png) left center no-repeat; padding-left:20px;}
|
||||
div.book-list div.add-wishlist, div.remove-wishlist { margin-right: 10px; padding-right: 10px; background:url(../images/booklist/booklist-vline.png) right center no-repeat; cursor: pointer;}
|
||||
div.book-list div.add-wishlist, div.remove-wishlist span{ font-weight:normal; color:#3d4e53; text-transform: none; background:url(../images/booklist/add-wishlist.png) left center no-repeat; padding-left:20px;}
|
||||
|
||||
div.book-list div.booklist-status{ margin-right:7px;}
|
||||
span.booklist-status-text{ float:left; display:block; padding-right:5px;}
|
||||
|
@ -49,3 +50,7 @@ ul.navigation li.arrow-l a{ background:url(../images/booklist/bg.png) 0 -168px n
|
|||
ul.navigation li.arrow-r a{ background:url(../images/booklist/bg.png) -1px -185px no-repeat;width:10px; height:15px; display:block; text-indent:-10000px;}
|
||||
|
||||
.unglue-button { display: block; border: 0;}
|
||||
|
||||
.book-thumb img {
|
||||
height: 50px;
|
||||
}
|
||||
|
|
|
@ -44,8 +44,8 @@ body{
|
|||
.search .button{ background:url(/static/images/bg.png) 100% -144px no-repeat; padding:0; margin:0; width:40px; height:36px; display:block; border:none; text-indent:-10000px; cursor:pointer;}
|
||||
|
||||
.first{
|
||||
color: #6994a3;
|
||||
font-weight: bold;
|
||||
color: #6994a3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#leftcol{ float:left; width:235px;}
|
||||
|
@ -146,7 +146,7 @@ a{ font-weight:bold; font-size:13px; text-decoration:none; cursor:pointer;}
|
|||
}
|
||||
|
||||
#footer{
|
||||
border-top: 7px solid #edf3f4;
|
||||
clear: both;
|
||||
height:90px;
|
||||
}
|
||||
border-top: 7px solid #edf3f4;
|
||||
clear: both;
|
||||
height:90px;
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ div.content-block-content .cols3 .column{ width:33.33%; float:left;}
|
|||
.column-left .item{ margin:0 10px 10px 0;}
|
||||
.column-center .item{ margin:0 5px 10px 5px;}
|
||||
.column-right .item{ margin:0 0 10px 10px;}
|
||||
.column .item{ border:7px solid #edf3f4; padding:10px;}
|
||||
.column .item{ border:8px solid #edf3f4; padding:10px;}
|
||||
.book-image{ padding:0 0 10px 0;}
|
||||
.book-info{ padding:0 0 10px 0; line-height:125%; position:relative;}
|
||||
.book-info span.book-new{ background:url(/static/images/icon-new.png) 0 0 no-repeat; width:38px; height:36px; display:block; position:absolute;
|
||||
|
@ -150,4 +150,4 @@ a{ font-weight:bold; font-size:13px; text-decoration:none; cursor:pointer;}
|
|||
|
||||
#footer a{
|
||||
color:#3d4e53;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue