Merge remote-tracking branch 'Gluejar/master'
commit
170a6b7b16
|
@ -38,7 +38,7 @@ from . import cc
|
|||
from . import models
|
||||
from .parameters import WORK_IDENTIFIERS
|
||||
from .validation import identifier_cleaner
|
||||
from .loaders.scrape import BaseScraper, scrape_sitemap
|
||||
from .loaders.scrape import get_scraper, scrape_sitemap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
request_log = logging.getLogger("requests")
|
||||
|
@ -649,35 +649,11 @@ def add_openlibrary(work, hard_refresh = False):
|
|||
|
||||
# add the subjects to the Work
|
||||
for s in subjects:
|
||||
if valid_subject(s):
|
||||
logger.info("adding subject %s to work %s", s, work.id)
|
||||
subject, created = models.Subject.objects.get_or_create(name=s)
|
||||
work.subjects.add(subject)
|
||||
logger.info("adding subject %s to work %s", s, work.id)
|
||||
subject = models.Subject.set_by_name(s, work=work)
|
||||
|
||||
work.save()
|
||||
|
||||
def valid_xml_char_ordinal(c):
|
||||
codepoint = ord(c)
|
||||
# conditions ordered by presumed frequency
|
||||
return (
|
||||
0x20 <= codepoint <= 0xD7FF or
|
||||
codepoint in (0x9, 0xA, 0xD) or
|
||||
0xE000 <= codepoint <= 0xFFFD or
|
||||
0x10000 <= codepoint <= 0x10FFFF
|
||||
)
|
||||
|
||||
def valid_subject( subject_name ):
|
||||
num_commas = 0
|
||||
for c in subject_name:
|
||||
if not valid_xml_char_ordinal(c):
|
||||
return False
|
||||
if c == ',':
|
||||
num_commas += 1
|
||||
if num_commas > 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _get_json(url, params={}, type='gb'):
|
||||
# TODO: should X-Forwarded-For change based on the request from client?
|
||||
|
@ -909,11 +885,8 @@ class BasePandataLoader(object):
|
|||
(authority, heading) = ( '', yaml_subject)
|
||||
else:
|
||||
continue
|
||||
(subject, created) = models.Subject.objects.get_or_create(name=heading)
|
||||
if not subject.authority and authority:
|
||||
subject.authority = authority
|
||||
subject.save()
|
||||
subject.works.add(work)
|
||||
subject = models.Subject.set_by_name(heading, work=work, authority=authority)
|
||||
|
||||
# the default edition uses the first cover in covers.
|
||||
for cover in metadata.covers:
|
||||
if cover.get('image_path', False):
|
||||
|
@ -1050,7 +1023,7 @@ def ebooks_in_github_release(repo_owner, repo_name, tag, token=None):
|
|||
|
||||
def add_by_webpage(url, work=None, user=None):
|
||||
edition = None
|
||||
scraper = BaseScraper(url)
|
||||
scraper = get_scraper(url)
|
||||
loader = BasePandataLoader(url)
|
||||
pandata = Pandata()
|
||||
pandata.metadata = scraper.metadata
|
||||
|
@ -1062,7 +1035,6 @@ def add_by_webpage(url, work=None, user=None):
|
|||
|
||||
def add_by_sitemap(url, maxnum=None):
|
||||
editions = []
|
||||
scraper = BaseScraper(url)
|
||||
for bookdata in scrape_sitemap(url, maxnum=maxnum):
|
||||
edition = work = None
|
||||
loader = BasePandataLoader(bookdata.base)
|
||||
|
|
|
@ -221,7 +221,7 @@ class KeywordFacetGroup(FacetGroup):
|
|||
def set_name(self):
|
||||
self.facet_name=facet_name
|
||||
# facet_names of the form 'kw.SUBJECT' and SUBJECT is therefore the 4th character on
|
||||
self.keyword=self.facet_name[3:]
|
||||
self.keyword=self.facet_name[3:].replace(';', '/')
|
||||
def get_query_set(self):
|
||||
return self._get_query_set().filter(subjects__name=self.keyword)
|
||||
def template(self):
|
||||
|
|
|
@ -18,6 +18,7 @@ from regluit.core import models, tasks
|
|||
from regluit.core import bookloader
|
||||
from regluit.core.bookloader import add_by_isbn, merge_works
|
||||
from regluit.core.isbn import ISBN
|
||||
from regluit.core.validation import valid_subject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -100,8 +101,8 @@ def attach_more_doab_metadata(edition, description, subjects,
|
|||
|
||||
# update subjects
|
||||
for s in subjects:
|
||||
if bookloader.valid_subject(s):
|
||||
work.subjects.add(models.Subject.objects.get_or_create(name=s)[0])
|
||||
if valid_subject(s):
|
||||
models.Subject.set_by_name(s, work=work)
|
||||
|
||||
# set reading level of work if it's empty; doab is for adults.
|
||||
if not work.age_level:
|
||||
|
|
|
@ -7,14 +7,14 @@ from django.conf import settings
|
|||
from urlparse import urljoin
|
||||
|
||||
from regluit.core import models
|
||||
from regluit.core.validation import identifier_cleaner
|
||||
from regluit.core.validation import identifier_cleaner, authlist_cleaner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONTAINS_COVER = re.compile('cover')
|
||||
CONTAINS_CC = re.compile('creativecommons.org')
|
||||
|
||||
class BaseScraper(object):
|
||||
class BaseScraper(object):
|
||||
'''
|
||||
designed to make at least a decent gues for webpages that embed metadata
|
||||
'''
|
||||
|
@ -47,6 +47,10 @@ class BaseScraper(object):
|
|||
logger.error(e)
|
||||
self.metadata = {}
|
||||
self.metadata['identifiers'] = self.identifiers
|
||||
|
||||
#
|
||||
# utilities
|
||||
#
|
||||
|
||||
def set(self, name, value):
|
||||
self.metadata[name] = value
|
||||
|
@ -65,6 +69,12 @@ class BaseScraper(object):
|
|||
attrs['name'] = meta_name
|
||||
|
||||
metas = self.doc.find_all('meta', attrs=attrs)
|
||||
if len(metas) == 0:
|
||||
# some sites put schema.org metadata in metas
|
||||
del(attrs['name'])
|
||||
attrs['itemprop'] = meta_name
|
||||
metas = self.doc.find_all('meta', attrs=attrs)
|
||||
del(attrs['itemprop'])
|
||||
for meta in metas:
|
||||
el_value = meta.get('content', '').strip()
|
||||
if list_mode == 'longest':
|
||||
|
@ -78,6 +88,16 @@ class BaseScraper(object):
|
|||
if value:
|
||||
return value
|
||||
return value
|
||||
|
||||
def get_dt_dd(self, name):
|
||||
''' get the content of <dd> after a <dt> containing name'''
|
||||
dt = self.doc.find('dt', string=re.compile(name))
|
||||
dd = dt.find_next_sibling('dd') if dt else None
|
||||
return dd.text if dd else None
|
||||
|
||||
#
|
||||
# getters
|
||||
#
|
||||
|
||||
def get_genre(self):
|
||||
value = self.check_metas(['DC.Type', 'dc.type', 'og:type'])
|
||||
|
@ -91,7 +111,7 @@ class BaseScraper(object):
|
|||
self.set('title', value)
|
||||
|
||||
def get_language(self):
|
||||
value = self.check_metas(['DC.Language', 'dc.language', 'language'])
|
||||
value = self.check_metas(['DC.Language', 'dc.language', 'language', 'inLanguage'])
|
||||
self.set('language', value)
|
||||
|
||||
def get_description(self):
|
||||
|
@ -103,15 +123,8 @@ class BaseScraper(object):
|
|||
])
|
||||
self.set('description', value)
|
||||
|
||||
def get_identifiers(self):
|
||||
value = self.check_metas(['DC.Identifier.URI'])
|
||||
value = identifier_cleaner('http')(value)
|
||||
if value:
|
||||
self.identifiers['http'] = value
|
||||
value = self.check_metas(['DC.Identifier.DOI', 'citation_doi'])
|
||||
value = identifier_cleaner('doi')(value)
|
||||
if value:
|
||||
self.identifiers['doi'] = value
|
||||
def get_isbns(self):
|
||||
'''return a dict of edition keys and ISBNs'''
|
||||
isbns = {}
|
||||
label_map = {'epub': 'EPUB', 'mobi': 'Mobi',
|
||||
'paper': 'Paperback', 'pdf':'PDF', 'hard':'Hardback'}
|
||||
|
@ -122,7 +135,22 @@ class BaseScraper(object):
|
|||
if value:
|
||||
isbns[isbn_key] = value
|
||||
self.identifiers[isbn_key] = value
|
||||
|
||||
return isbns
|
||||
|
||||
def get_identifiers(self):
|
||||
value = self.check_metas(['DC.Identifier.URI'])
|
||||
if not value:
|
||||
value = self.doc.select_one('link[rel=canonical]')
|
||||
value = value['href'] if value else None
|
||||
value = identifier_cleaner('http')(value)
|
||||
if value:
|
||||
self.identifiers['http'] = value
|
||||
value = self.check_metas(['DC.Identifier.DOI', 'citation_doi'])
|
||||
value = identifier_cleaner('doi')(value)
|
||||
if value:
|
||||
self.identifiers['doi'] = value
|
||||
|
||||
isbns = self.get_isbns()
|
||||
ed_list = []
|
||||
if len(isbns):
|
||||
#need to create edition list
|
||||
|
@ -156,7 +184,7 @@ class BaseScraper(object):
|
|||
self.set('publisher', value)
|
||||
|
||||
def get_pubdate(self):
|
||||
value = self.check_metas(['citation_publication_date', 'DC.Date.issued'])
|
||||
value = self.check_metas(['citation_publication_date', 'DC.Date.issued', 'datePublished'])
|
||||
if value:
|
||||
self.set('publication_date', value)
|
||||
|
||||
|
@ -164,21 +192,22 @@ class BaseScraper(object):
|
|||
value_list = self.check_metas([
|
||||
'DC.Creator.PersonalName',
|
||||
'citation_author',
|
||||
'author',
|
||||
], list_mode='list')
|
||||
if not value_list:
|
||||
return
|
||||
creator_list = []
|
||||
value_list = authlist_cleaner(value_list)
|
||||
if len(value_list) == 1:
|
||||
creator = {'author': {'agent_name': value_list[0]}}
|
||||
else:
|
||||
creator_list = []
|
||||
for auth in value_list:
|
||||
creator_list.append({'agent_name': auth})
|
||||
creator = {'authors': creator_list }
|
||||
|
||||
self.set('creator', creator)
|
||||
self.set('creator', {'author': {'agent_name': value_list[0]}})
|
||||
return
|
||||
for auth in value_list:
|
||||
creator_list.append({'agent_name': auth})
|
||||
|
||||
self.set('creator', {'authors': creator_list })
|
||||
|
||||
def get_cover(self):
|
||||
image_url = self.check_metas(['og.image'])
|
||||
image_url = self.check_metas(['og.image', 'image'])
|
||||
if not image_url:
|
||||
block = self.doc.find(class_=CONTAINS_COVER)
|
||||
block = block if block else self.doc
|
||||
|
@ -203,12 +232,65 @@ class BaseScraper(object):
|
|||
for link in links:
|
||||
self.set('rights_url', link['href'])
|
||||
|
||||
@classmethod
|
||||
def can_scrape(cls, url):
|
||||
''' return True if the class can scrape the URL '''
|
||||
return True
|
||||
|
||||
class PressbooksScraper(BaseScraper):
|
||||
def get_downloads(self):
|
||||
for dl_type in ['epub', 'mobi', 'pdf']:
|
||||
download_el = self.doc.select_one('.{}'.format(dl_type))
|
||||
if download_el and download_el.find_parent():
|
||||
value = download_el.find_parent().get('href')
|
||||
if value:
|
||||
self.set('download_url_{}'.format(dl_type), value)
|
||||
|
||||
def get_publisher(self):
|
||||
value = self.get_dt_dd('Publisher')
|
||||
if not value:
|
||||
value = self.doc.select_one('.cie-name')
|
||||
value = value.text if value else None
|
||||
if value:
|
||||
self.set('publisher', value)
|
||||
else:
|
||||
super(PressbooksScraper, self).get_publisher()
|
||||
|
||||
def get_title(self):
|
||||
value = self.doc.select_one('.entry-title a[title]')
|
||||
value = value['title'] if value else None
|
||||
if value:
|
||||
self.set('title', value)
|
||||
else:
|
||||
super(PressbooksScraper, self).get_title()
|
||||
|
||||
def get_isbns(self):
|
||||
'''add isbn identifiers and return a dict of edition keys and ISBNs'''
|
||||
isbns = {}
|
||||
for (key, label) in [('electronic', 'Ebook ISBN'), ('paper', 'Print ISBN')]:
|
||||
isbn = identifier_cleaner('isbn')(self.get_dt_dd(label))
|
||||
if isbn:
|
||||
self.identifiers['isbn_{}'.format(key)] = isbn
|
||||
isbns[key] = isbn
|
||||
return isbns
|
||||
|
||||
@classmethod
|
||||
def can_scrape(cls, url):
|
||||
''' return True if the class can scrape the URL '''
|
||||
return url.find('press.rebus.community') > 0 or url.find('pressbooks.com') > 0
|
||||
|
||||
def get_scraper(url):
|
||||
scrapers = [PressbooksScraper, BaseScraper]
|
||||
for scraper in scrapers:
|
||||
if scraper.can_scrape(url):
|
||||
return scraper(url)
|
||||
|
||||
def scrape_sitemap(url, maxnum=None):
|
||||
try:
|
||||
response = requests.get(url, headers={"User-Agent": settings.USER_AGENT})
|
||||
doc = BeautifulSoup(response.content, 'lxml')
|
||||
for page in doc.find_all('loc')[0:maxnum]:
|
||||
scraper = BaseScraper(page.text)
|
||||
scraper = get_scraper(page.text)
|
||||
if scraper.metadata.get('genre', None) == 'book':
|
||||
yield scraper
|
||||
except requests.exceptions.RequestException as e:
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
|
||||
from regluit.core.models import Subject
|
||||
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
'''Have observed that if the subject has more than two commas in it, it probably means something else'''
|
||||
help = "reprocess subjects containing ';' or starting with 'nyt:' or 'award:'"
|
||||
|
||||
def handle(self, **options):
|
||||
semicolon_subjects = Subject.objects.filter(name__contains=";")
|
||||
|
||||
for subject in semicolon_subjects:
|
||||
for work in subject.works.all():
|
||||
Subject.set_by_name(subject.name, work=work)
|
||||
subject.delete()
|
||||
|
||||
nyt_subjects = Subject.objects.filter(name__startswith="nyt:")
|
||||
for subject in nyt_subjects:
|
||||
for work in subject.works.all():
|
||||
Subject.set_by_name(subject.name, work=work)
|
||||
subject.delete()
|
||||
|
||||
award_subjects = Subject.objects.filter(name__startswith="award:")
|
||||
for subject in award_subjects:
|
||||
for work in subject.works.all():
|
||||
Subject.set_by_name(subject.name, work=work)
|
||||
subject.delete()
|
|
@ -30,6 +30,7 @@ from regluit.core import mobi
|
|||
import regluit.core.cc as cc
|
||||
from regluit.core.epub import test_epub
|
||||
from regluit.core.links import id_url
|
||||
from regluit.core.validation import valid_subject
|
||||
|
||||
from regluit.core.parameters import (
|
||||
AGE_LEVEL_CHOICES,
|
||||
|
@ -759,7 +760,42 @@ class Subject(models.Model):
|
|||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@classmethod
|
||||
def set_by_name(cls, subject, work=None, authority=None):
|
||||
''' use this method whenever you would be creating a new subject!'''
|
||||
subject = subject.strip()
|
||||
|
||||
# make sure it's not a ; delineated list
|
||||
subjects = subject.split(';')
|
||||
for additional_subject in subjects[1:]:
|
||||
cls.set_by_name(additional_subject, work, authority)
|
||||
subject = subjects[0]
|
||||
# make sure there's no heading
|
||||
headingmatch = re.match(r'^!(.+):(.+)', subject)
|
||||
if headingmatch:
|
||||
subject = headingmatch.group(2).strip()
|
||||
authority = headingmatch.group(1).strip()
|
||||
elif subject.startswith('nyt:'):
|
||||
subject = subject[4:].split('=')[0].replace('_', ' ').strip().capitalize()
|
||||
subject = 'NYT Bestseller - {}'.format(subject)
|
||||
authority = 'nyt'
|
||||
elif subject.startswith('award:'):
|
||||
subject = subject[6:].split('=')[0].replace('_', ' ').strip().capitalize()
|
||||
subject = 'Award Winner - {}'.format(subject)
|
||||
authority = 'award'
|
||||
|
||||
if valid_subject(subject):
|
||||
(subject_obj, created) = cls.objects.get_or_create(name=subject)
|
||||
if not subject_obj.authority and authority:
|
||||
subject_obj.authority = authority
|
||||
subject_obj.save()
|
||||
|
||||
subject_obj.works.add(work)
|
||||
return subject_obj
|
||||
else:
|
||||
return None
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
|
|
|
@ -64,7 +64,8 @@ from regluit.core.models import (
|
|||
)
|
||||
from regluit.libraryauth.models import Library
|
||||
from regluit.core.parameters import TESTING, LIBRARY, RESERVE
|
||||
from regluit.core.loaders.utils import (load_from_books, loaded_book_ok)
|
||||
from regluit.core.loaders.utils import (load_from_books, loaded_book_ok, )
|
||||
from regluit.core.validation import valid_subject
|
||||
from regluit.frontend.views import safe_get_work
|
||||
from regluit.payment.models import Transaction
|
||||
from regluit.payment.parameters import PAYMENT_TYPE_AUTHORIZATION
|
||||
|
@ -107,11 +108,6 @@ class BookLoaderTests(TestCase):
|
|||
f = ebook.get_archive()
|
||||
self.assertTrue(EbookFile.objects.all().count()>num_ebf)
|
||||
|
||||
def test_valid_subject(self):
|
||||
self.assertTrue(bookloader.valid_subject('A, valid, suj\xc3t'))
|
||||
self.assertFalse(bookloader.valid_subject('A, valid, suj\xc3t, '))
|
||||
self.assertFalse(bookloader.valid_subject('A valid suj\xc3t \x01'))
|
||||
|
||||
def test_add_by_isbn_mock(self):
|
||||
with requests_mock.Mocker(real_http=True) as m:
|
||||
with open(os.path.join(TESTDIR, 'gb_hamilton.json')) as gb:
|
||||
|
@ -123,13 +119,13 @@ class BookLoaderTests(TestCase):
|
|||
if not (mocking or settings.TEST_INTEGRATION):
|
||||
return
|
||||
# edition
|
||||
edition = bookloader.add_by_isbn('9781594200090')
|
||||
edition = bookloader.add_by_isbn('9780143034759')
|
||||
self.assertEqual(edition.title, u'Alexander Hamilton')
|
||||
self.assertEqual(edition.publication_date, u'2004')
|
||||
self.assertEqual(edition.publisher, u'Perseus Books Group')
|
||||
self.assertEqual(edition.isbn_10, '1594200092')
|
||||
self.assertEqual(edition.isbn_13, '9781594200090')
|
||||
self.assertEqual(edition.googlebooks_id, 'y1_R-rjdcb0C')
|
||||
self.assertEqual(edition.publication_date, u'2005')
|
||||
self.assertEqual(edition.publisher, u'Penguin')
|
||||
self.assertEqual(edition.isbn_10, '0143034758')
|
||||
self.assertEqual(edition.isbn_13, '9780143034759')
|
||||
self.assertEqual(edition.googlebooks_id, '4iafgTEhU3QC')
|
||||
|
||||
# authors
|
||||
self.assertEqual(edition.authors.all().count(), 1)
|
||||
|
@ -137,12 +133,12 @@ class BookLoaderTests(TestCase):
|
|||
|
||||
# work
|
||||
self.assertTrue(edition.work)
|
||||
self.assertEqual(edition.work.googlebooks_id, 'y1_R-rjdcb0C')
|
||||
self.assertEqual(edition.work.first_isbn_13(), '9781594200090')
|
||||
self.assertEqual(edition.work.googlebooks_id, '4iafgTEhU3QC')
|
||||
self.assertEqual(edition.work.first_isbn_13(), '9780143034759')
|
||||
|
||||
# test duplicate pubname
|
||||
ed2 = Edition.objects.create(work=edition.work)
|
||||
ed2.set_publisher(u'Perseus Books Group')
|
||||
ed2.set_publisher(u'Penguin')
|
||||
|
||||
# publisher names
|
||||
old_pub_name = edition.publisher_name
|
||||
|
@ -153,7 +149,7 @@ class BookLoaderTests(TestCase):
|
|||
self.assertEqual(edition.work.publishers().count(), 1)
|
||||
old_pub_name.publisher = pub
|
||||
old_pub_name.save()
|
||||
edition.set_publisher(u'Perseus Books Group')
|
||||
edition.set_publisher(u'Penguin')
|
||||
self.assertEqual(edition.publisher, u'test publisher name') # Perseus has been aliased
|
||||
|
||||
def test_language_locale_mock(self):
|
||||
|
@ -879,17 +875,37 @@ class SafeGetWorkTest(TestCase):
|
|||
self.assertRaises(Http404, safe_get_work, 3)
|
||||
|
||||
class WorkTests(TestCase):
|
||||
def setUp(self):
|
||||
self.w1 = models.Work.objects.create()
|
||||
self.w2 = models.Work.objects.create()
|
||||
|
||||
def test_preferred_edition(self):
|
||||
w1 = models.Work.objects.create()
|
||||
w2 = models.Work.objects.create()
|
||||
ww = models.WasWork.objects.create(work=w1, was= w2.id)
|
||||
e1 = models.Edition.objects.create(work=w1)
|
||||
self.assertEqual(e1, w1.preferred_edition)
|
||||
e2 = models.Edition.objects.create(work=w1)
|
||||
w1.selected_edition=e2
|
||||
w1.save()
|
||||
self.assertEqual(e2, w1.preferred_edition)
|
||||
self.assertEqual(e2, w2.preferred_edition)
|
||||
ww = models.WasWork.objects.create(work=self.w1, was= self.w2.id)
|
||||
e1 = models.Edition.objects.create(work=self.w1)
|
||||
self.assertEqual(e1, self.w1.preferred_edition)
|
||||
e2 = models.Edition.objects.create(work=self.w1)
|
||||
self.w1.selected_edition=e2
|
||||
self.w1.save()
|
||||
self.assertEqual(e2, self.w1.preferred_edition)
|
||||
self.assertEqual(e2, self.w2.preferred_edition)
|
||||
|
||||
def test_valid_subject(self):
|
||||
self.assertTrue(valid_subject('A, valid, suj\xc3t'))
|
||||
self.assertFalse(valid_subject('A, valid, suj\xc3t, '))
|
||||
self.assertFalse(valid_subject('A valid suj\xc3t \x01'))
|
||||
Subject.set_by_name('A, valid, suj\xc3t; A, valid, suj\xc3t, ', work=self.w1)
|
||||
self.assertEqual(1, self.w1.subjects.count())
|
||||
sub = Subject.set_by_name('nyt:hardcover_advice=2011-06-18', work=self.w1)
|
||||
self.assertEqual(sub.name, 'NYT Bestseller - Hardcover advice')
|
||||
self.assertEqual(2, self.w1.subjects.count())
|
||||
sub2 = Subject.set_by_name('!lcsh: Something', work=self.w1)
|
||||
self.assertEqual(sub2.name, 'Something')
|
||||
self.assertEqual(3, self.w1.subjects.count())
|
||||
sub3 = Subject.set_by_name('Something', work=self.w1)
|
||||
self.assertEqual(sub3.name, 'Something')
|
||||
self.assertEqual(3, self.w1.subjects.count())
|
||||
self.assertEqual(sub3.authority, 'lcsh')
|
||||
|
||||
|
||||
class DownloadPageTest(TestCase):
|
||||
fixtures = ['initial_data.json']
|
||||
|
|
|
@ -108,3 +108,53 @@ def test_file(the_file, fformat):
|
|||
raise forms.ValidationError(_('%s is not a valid PDF file' % the_file.name) )
|
||||
return True
|
||||
|
||||
def valid_xml_char_ordinal(c):
|
||||
codepoint = ord(c)
|
||||
# conditions ordered by presumed frequency
|
||||
return (
|
||||
0x20 <= codepoint <= 0xD7FF or
|
||||
codepoint in (0x9, 0xA, 0xD) or
|
||||
0xE000 <= codepoint <= 0xFFFD or
|
||||
0x10000 <= codepoint <= 0x10FFFF
|
||||
)
|
||||
|
||||
def valid_subject( subject_name ):
|
||||
num_commas = 0
|
||||
for c in subject_name:
|
||||
if not valid_xml_char_ordinal(c):
|
||||
return False
|
||||
if c == ',':
|
||||
num_commas += 1
|
||||
if num_commas > 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
def authlist_cleaner(authlist):
|
||||
''' given a author string or list of author strings, checks that the author string
|
||||
is not a list of author names and that no author is repeated'''
|
||||
if isinstance(authlist, str):
|
||||
authlist = [authlist]
|
||||
cleaned = []
|
||||
for auth in authlist:
|
||||
for cleaned_auth in auth_cleaner(auth):
|
||||
if cleaned_auth not in cleaned:
|
||||
cleaned.append(cleaned_auth)
|
||||
return cleaned
|
||||
|
||||
# Match comma but not ", Jr"
|
||||
comma_list_delim = re.compile(r',(?! *Jr[\., ])')
|
||||
spaces = re.compile(r'\s+')
|
||||
_and_ = re.compile(r',? and ')
|
||||
|
||||
def auth_cleaner(auth):
|
||||
''' given a author string checks that the author string
|
||||
is not a list of author names'''
|
||||
cleaned = []
|
||||
auth = _and_.sub(',', auth)
|
||||
if ';' in auth:
|
||||
authlist = auth.split(';')
|
||||
else:
|
||||
authlist = comma_list_delim.split(auth)
|
||||
for auth in authlist:
|
||||
cleaned.append(spaces.sub(' ', auth.strip()))
|
||||
return cleaned
|
||||
|
|
|
@ -167,7 +167,7 @@ class EbookForm(forms.ModelForm):
|
|||
format = self.cleaned_data.get('format', '')
|
||||
the_file = self.cleaned_data.get('file', None)
|
||||
url = self.cleaned_data.get('url', None)
|
||||
test_file(the_file)
|
||||
test_file(the_file, format)
|
||||
if not the_file and not url:
|
||||
raise forms.ValidationError(_("Either a link or a file is required."))
|
||||
if the_file and url:
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<div>
|
||||
<h2>Other Account Tools</h2>
|
||||
<ul>
|
||||
<li>Want to <a href="{% url 'auth_password_change' %}">change your password</a>?</li>
|
||||
<li>Want to <a href="{% url 'libraryauth_password_change' %}">change your password</a>?</li>
|
||||
<li>... or <a href="{% url 'manage_account' %}">manage your pledges and payment info</a>?</li>
|
||||
<li>... or <a href="{% url 'edit_user' %}">change your username</a>?</li>
|
||||
<li>... or <a href="{% url 'notification_notice_settings' %}">manage your contact preferences</a>?</li>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<span>Show me...</span>
|
||||
<ul class="menu level2">
|
||||
{% if not request.user.is_anonymous %}
|
||||
<li><a href="/"><span>My Faves</span></a></li>
|
||||
<li><a href="{% url 'supporter' request.user %}"><span>My Faves</span></a></li>
|
||||
{% for library in request.user.profile.libraries %}
|
||||
<li><a href="{% url 'library' library.user %}"><span>{{ library }}</span></a></li>
|
||||
{% endfor %}
|
||||
|
|
|
@ -22,7 +22,7 @@ Make sure the username box has your <b>username, not your email</b> -- some brow
|
|||
<br />
|
||||
|
||||
|
||||
<a href="{% url 'auth_password_reset' %}?next={% url 'receive_gift' gift.acq.nonce %}">Forgot</a> your password? <a href="{% url 'registration_register' %}?next={% url 'receive_gift' gift.acq.nonce %}">Need an account</a>? <a href="/faq/basics/account">Other questions</a>?
|
||||
<a href="{% url 'libraryauth_password_reset' %}?next={% url 'receive_gift' gift.acq.nonce %}">Forgot</a> your password? <a href="{% url 'registration_register' %}?next={% url 'receive_gift' gift.acq.nonce %}">Need an account</a>? <a href="/faq/basics/account">Other questions</a>?
|
||||
|
||||
|
||||
<br /><br />
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
<h2> Other Account Management Tools </h2>
|
||||
<ul>
|
||||
<li>Want to <a href="{% url 'auth_password_change' %}">change your password</a>?</li>
|
||||
<li>Want to <a href="{% url 'libraryauth_password_change' %}">change your password</a>?</li>
|
||||
<li>... or <a href="{% url 'notification_notice_settings' %}">manage your contact preferences</a>?</li>
|
||||
<li>... or <a href="{% url 'email_change' %}">change your email address</a>?</li>
|
||||
<li>... or <a href="{% url 'edit_user' %}">change your username</a>?</li>
|
||||
|
|
|
@ -104,7 +104,7 @@ You can complete your last transaction by <a href="{% url 'fund' user.profile.la
|
|||
|
||||
<h2> Other Account Management Tools </h2>
|
||||
<ul>
|
||||
<li>Want to <a href="{% url 'auth_password_change' %}">change your password</a>?</li>
|
||||
<li>Want to <a href="{% url 'libraryauth_password_change' %}">change your password</a>?</li>
|
||||
<li>... or <a href="{% url 'notification_notice_settings' %}">manage your contact preferences</a>?</li>
|
||||
<li>... or <a href="{% url 'email_change' %}">change your email address</a>?</li>
|
||||
<li>... or <a href="{% url 'edit_user' %}">change your username</a>?</li>
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<a href="{% url 'auth_password_reset' %}">Forgot</a> your password?
|
||||
<a href="{% url 'libraryauth_password_reset' %}">Forgot</a> your password?
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
|
|
|
@ -71,7 +71,7 @@ function put_un_in_cookie(){
|
|||
</div>
|
||||
<div class="halfcolumn1 login_box">
|
||||
<h3>Already Have an Unglue.it Account?</h3>
|
||||
<a href="{% url 'auth_password_reset' %}?next={% if request.GET.next %}{{ request.GET.next|urlencode }}{% else %}{{ request.get_full_path|urlencode}}{% endif %}">Forgot</a> your password? </li>
|
||||
<a href="{% url 'libraryauth_password_reset' %}?next={% if request.GET.next %}{{ request.GET.next|urlencode }}{% else %}{{ request.get_full_path|urlencode}}{% endif %}">Forgot</a> your password? </li>
|
||||
{% include "login_form.html" %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ Make sure the username box has your <b>username, not your email</b> -- some brow
|
|||
<br />
|
||||
|
||||
|
||||
<a href="{% url 'auth_password_reset' %}?next={% if request.GET.next %}{{ request.GET.next|urlencode }}{% else %}{{ request.get_full_path|urlencode}}{% endif %}">Forgot</a> your password? <a href="{% url 'registration_register' %}?next={% if request.GET.next %}{{ request.GET.next|urlencode }}{% else %}{{ request.get_full_path|urlencode}}{% endif %}">Need an account</a>? <a href="/faq/basics/account">Other questions</a>?
|
||||
<a href="{% url 'libraryauth_password_reset' %}?next={% if request.GET.next %}{{ request.GET.next|urlencode }}{% else %}{{ request.get_full_path|urlencode}}{% endif %}">Forgot</a> your password? <a href="{% url 'registration_register' %}?next={% if request.GET.next %}{{ request.GET.next|urlencode }}{% else %}{{ request.get_full_path|urlencode}}{% endif %}">Need an account</a>? <a href="/faq/basics/account">Other questions</a>?
|
||||
|
||||
|
||||
<br /><br />
|
||||
|
|
|
@ -4,25 +4,12 @@
|
|||
{% block doccontent %}
|
||||
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if not request.user.has_usable_password %}
|
||||
<div>
|
||||
Because you registered using your account on another site (such as Google), you'll need to reset your password before you can change it.
|
||||
</div>
|
||||
<form method='post' action='{% url 'social_auth_reset_password' %}'>{% csrf_token %}
|
||||
|
||||
<input id="id_email" type="hidden" name="email" value="{{request.user.email}}" />
|
||||
|
||||
<p><input type='submit' value="Reset password" /></p>
|
||||
|
||||
</form>
|
||||
{% else %}
|
||||
<form method='post' action=''>{% csrf_token %}
|
||||
|
||||
{{ form.as_p }}
|
||||
<p><input type='submit' value="Change password" /></p>
|
||||
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div>You must be logged in to change your password.</div>
|
||||
<a href="{% url 'superlogin' %}?next={% if request.GET.next %}{{ request.GET.next|urlencode }}{% else %}/next/{% endif %}" class="nounderline"><div class="actionbutton">Log in</div></a>
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<div>
|
||||
<h2>Other Account Tools</h2>
|
||||
<ul>
|
||||
<li>Want to <a href="{% url 'auth_password_change' %}">change your password</a>?</li>
|
||||
<li>Want to <a href="{% url 'libraryauth_password_change' %}">change your password</a>?</li>
|
||||
<li>... or <a href="{% url 'manage_account' %}">manage your pledges and payment info</a>?</li>
|
||||
<li>... or <a href="{% url 'email_change' %}">change your email address</a>?</li>
|
||||
<li>... or <a href="{% url 'notification_notice_settings' %}">manage your contact preferences</a>?</li>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from registration.forms import RegistrationForm
|
||||
|
@ -57,7 +58,6 @@ class RegistrationFormNoDisposableEmail(RegistrationForm):
|
|||
if is_disposable(self.cleaned_data['email']):
|
||||
raise forms.ValidationError(_("Please supply a permanent email address."))
|
||||
return self.cleaned_data['email']
|
||||
|
||||
|
||||
class AuthForm(AuthenticationForm):
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
|
@ -67,6 +67,22 @@ class AuthForm(AuthenticationForm):
|
|||
else:
|
||||
super(AuthForm, self).__init__(*args, **kwargs)
|
||||
|
||||
class SocialAwarePasswordResetForm(PasswordResetForm):
|
||||
def get_users(self, email):
|
||||
"""
|
||||
Send the reset form even if the user password is not usable
|
||||
"""
|
||||
active_users = get_user_model()._default_manager.filter(
|
||||
email__iexact=email, is_active=True)
|
||||
return active_users
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if not get_user_model().objects.filter(email__iexact=email, is_active=True).exists():
|
||||
raise forms.ValidationError("There aren't ungluers with that email address!")
|
||||
return email
|
||||
|
||||
|
||||
class NewLibraryForm(forms.ModelForm):
|
||||
username = forms.RegexField(
|
||||
label=_("Library Username"),
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from django.conf.urls import patterns, url, include
|
||||
from django.core.urlresolvers import reverse
|
||||
#from django.views.generic.simple import direct_to_template
|
||||
from django.core.urlresolvers import reverse_lazy
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.views import password_reset
|
||||
from . import views, models, forms
|
||||
from .views import superlogin
|
||||
|
||||
|
@ -20,23 +20,23 @@ class ExtraContextTemplateView(TemplateView):
|
|||
return context
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^libraryauth/(?P<library_id>\d+)/join/$", views.join_library, name="join_library"),
|
||||
url(r"^libraryauth/(?P<library_id>\d+)/deny/$", TemplateView.as_view(template_name='libraryauth/denied.html'), name="bad_library"),
|
||||
url(r"^libraryauth/(?P<library_id>\d+)/users/$", views.library, {'template':'libraryauth/users.html'}, name="library_users"),
|
||||
url(r"^libraryauth/(?P<library_id>\d+)/admin/$", login_required(views.UpdateLibraryView.as_view()), name="library_admin"),
|
||||
url(r"^libraryauth/(?P<library_id>\d+)/login/$", views.login_as_library, name="library_login"),
|
||||
url(r"^libraryauth/create/$", login_required(views.CreateLibraryView.as_view()), name="library_create"),
|
||||
url(r"^libraryauth/list/$", ExtraContextTemplateView.as_view(
|
||||
url(r'^libraryauth/(?P<library_id>\d+)/join/$', views.join_library, name='join_library'),
|
||||
url(r'^libraryauth/(?P<library_id>\d+)/deny/$', TemplateView.as_view(template_name='libraryauth/denied.html'), name='bad_library'),
|
||||
url(r'^libraryauth/(?P<library_id>\d+)/users/$', views.library, {'template':'libraryauth/users.html'}, name='library_users'),
|
||||
url(r'^libraryauth/(?P<library_id>\d+)/admin/$', login_required(views.UpdateLibraryView.as_view()), name='library_admin'),
|
||||
url(r'^libraryauth/(?P<library_id>\d+)/login/$', views.login_as_library, name='library_login'),
|
||||
url(r'^libraryauth/create/$', login_required(views.CreateLibraryView.as_view()), name='library_create'),
|
||||
url(r'^libraryauth/list/$', ExtraContextTemplateView.as_view(
|
||||
template_name='libraryauth/list.html',
|
||||
extra_context={'libraries_to_show':'approved'}
|
||||
), name="library_list"),
|
||||
url(r"^libraryauth/unapproved/$", ExtraContextTemplateView.as_view(
|
||||
), name='library_list'),
|
||||
url(r'^libraryauth/unapproved/$', ExtraContextTemplateView.as_view(
|
||||
template_name='libraryauth/list.html',
|
||||
extra_context={'libraries_to_show':'new'}
|
||||
), name="new_libraries"),
|
||||
), name='new_libraries'),
|
||||
url(r'^accounts/register/$', views.CustomRegistrationView.as_view(), name='registration_register'),
|
||||
url(r'^accounts/superlogin/$', views.superlogin, name='superlogin'),
|
||||
url(r"^accounts/superlogin/welcome/$", ExtraContextTemplateView.as_view(
|
||||
url(r'^accounts/superlogin/welcome/$', ExtraContextTemplateView.as_view(
|
||||
template_name='registration/welcome.html',
|
||||
extra_context={'suppress_search_box': True,}
|
||||
) ),
|
||||
|
@ -50,12 +50,21 @@ urlpatterns = [
|
|||
{'template_name': 'registration/activation_complete.html'}),
|
||||
url(r'^accounts/login-error/$', superlogin,
|
||||
{'template_name': 'registration/from_error.html'}),
|
||||
url(r'^accounts/edit/$', views.edit_user, name="edit_user"),
|
||||
url(r"^accounts/login/welcome/$", ExtraContextTemplateView.as_view(
|
||||
url(r'^accounts/edit/$', views.edit_user, name='edit_user'),
|
||||
url(r'^accounts/login/welcome/$', ExtraContextTemplateView.as_view(
|
||||
template_name='registration/welcome.html',
|
||||
extra_context={'suppress_search_box': True,}
|
||||
) ),
|
||||
url(r'^socialauth/reset_password/$', views.social_auth_reset_password, name="social_auth_reset_password"),
|
||||
url(r'^accounts/password/change/$',
|
||||
views.social_aware_password_change,
|
||||
{'post_change_redirect': reverse_lazy('auth_password_change_done')},
|
||||
name='libraryauth_password_change'),
|
||||
url(r'^password/reset/$',
|
||||
password_reset,
|
||||
{'post_reset_redirect': reverse_lazy('auth_password_reset_done'),
|
||||
'password_reset_form': forms.SocialAwarePasswordResetForm},
|
||||
name='libraryauth_password_reset'),
|
||||
|
||||
url(r'^socialauth/', include('social.apps.django_app.urls', namespace='social')),
|
||||
url('accounts/', include('email_change.urls')),
|
||||
url(r'^accounts/', include('registration.backends.model_activation.urls')),
|
||||
|
|
|
@ -3,14 +3,16 @@ import random
|
|||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.contrib.auth.views import login, password_reset
|
||||
|
||||
from django.contrib.auth.forms import SetPasswordForm
|
||||
from django.contrib.auth.views import login, password_reset, password_change
|
||||
from django.contrib.auth import login as login_to_user
|
||||
from django.contrib.auth import load_backend
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views.generic.edit import FormView, CreateView, UpdateView, SingleObjectMixin
|
||||
|
||||
from registration.backends.model_activation.views import RegistrationView
|
||||
|
||||
from . import backends
|
||||
from .models import Library
|
||||
from .forms import AuthForm, LibraryForm, NewLibraryForm, RegistrationFormNoDisposableEmail, UserData
|
||||
|
@ -54,6 +56,10 @@ def superlogin(request, extra_context={}, **kwargs):
|
|||
request.session["add_wishlist"]=request.GET["add"]
|
||||
return login(request, extra_context=extra_context, authentication_form=AuthForm, **kwargs)
|
||||
|
||||
def social_aware_password_change(request, **kwargs):
|
||||
if request.user.has_usable_password():
|
||||
return password_change(request, **kwargs)
|
||||
return password_change(request, password_change_form=SetPasswordForm, **kwargs)
|
||||
|
||||
class Authenticator:
|
||||
request=None
|
||||
|
@ -266,11 +272,5 @@ def edit_user(request, redirect_to=None):
|
|||
return HttpResponseRedirect(redirect_to if redirect_to else reverse('home')) # Redirect after POST
|
||||
return render(request,'registration/user_change_form.html', {'form': form})
|
||||
|
||||
@login_required
|
||||
def social_auth_reset_password(request):
|
||||
if not request.user.has_usable_password():
|
||||
request.user.set_password('%010x' % random.randrange(16**10))
|
||||
request.user.save()
|
||||
return password_reset(request)
|
||||
|
||||
|
||||
|
|
|
@ -4,25 +4,25 @@
|
|||
"items": [
|
||||
{
|
||||
"kind": "books#volume",
|
||||
"id": "y1_R-rjdcb0C",
|
||||
"etag": "Z9u3yL2Mbk4",
|
||||
"selfLink": "https://www.googleapis.com/books/v1/volumes/y1_R-rjdcb0C",
|
||||
"id": "4iafgTEhU3QC",
|
||||
"etag": "oDa3LHhjykQ",
|
||||
"selfLink": "https://www.googleapis.com/books/v1/volumes/4iafgTEhU3QC",
|
||||
"volumeInfo": {
|
||||
"title": "Alexander Hamilton",
|
||||
"authors": [
|
||||
"Ron Chernow"
|
||||
],
|
||||
"publisher": "Perseus Books Group",
|
||||
"publishedDate": "2004",
|
||||
"description": "The personal life of Alexander Hamilton, an illegitimate, largely self-taught orphan from the Caribbean who rose to become George Washington's aide-de-camp and the first Treasury Secretary of the United States, is captured in a definitive biography by the National Book Award-winning author of The House of Morgan. 400,000 first printing.",
|
||||
"publisher": "Penguin",
|
||||
"publishedDate": "2005",
|
||||
"description": "The personal life of Alexander Hamilton, an illegitimate, largely self-taught orphan from the Caribbean who rose to become George Washington's aide-de-camp and the first Treasury Secretary of the United States, is captured in a definitive biography by the National Book Award-winning author of The House of Morgan. Reprint.",
|
||||
"industryIdentifiers": [
|
||||
{
|
||||
"type": "ISBN_10",
|
||||
"identifier": "1594200092"
|
||||
"identifier": "0143034758"
|
||||
},
|
||||
{
|
||||
"type": "ISBN_13",
|
||||
"identifier": "9781594200090"
|
||||
"identifier": "9780143034759"
|
||||
}
|
||||
],
|
||||
"readingModes": {
|
||||
|
@ -34,19 +34,19 @@
|
|||
"categories": [
|
||||
"Biography & Autobiography"
|
||||
],
|
||||
"averageRating": 4.0,
|
||||
"ratingsCount": 32,
|
||||
"averageRating": 4.5,
|
||||
"ratingsCount": 46,
|
||||
"maturityRating": "NOT_MATURE",
|
||||
"allowAnonLogging": false,
|
||||
"contentVersion": "preview-1.0.0",
|
||||
"imageLinks": {
|
||||
"smallThumbnail": "http://books.google.com/books/content?id=y1_R-rjdcb0C&printsec=frontcover&img=1&zoom=5&source=gbs_api",
|
||||
"thumbnail": "http://books.google.com/books/content?id=y1_R-rjdcb0C&printsec=frontcover&img=1&zoom=1&source=gbs_api"
|
||||
"smallThumbnail": "http://books.google.com/books/content?id=4iafgTEhU3QC&printsec=frontcover&img=1&zoom=5&edge=curl&source=gbs_api",
|
||||
"thumbnail": "http://books.google.com/books/content?id=4iafgTEhU3QC&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api"
|
||||
},
|
||||
"language": "en",
|
||||
"previewLink": "http://books.google.com/books?id=y1_R-rjdcb0C&dq=isbn:9781594200090&hl=&cd=1&source=gbs_api",
|
||||
"infoLink": "http://books.google.com/books?id=y1_R-rjdcb0C&dq=isbn:9781594200090&hl=&source=gbs_api",
|
||||
"canonicalVolumeLink": "http://books.google.com/books/about/Alexander_Hamilton.html?hl=&id=y1_R-rjdcb0C"
|
||||
"previewLink": "http://books.google.com/books?id=4iafgTEhU3QC&printsec=frontcover&dq=isbn:9780143034759&hl=&cd=1&source=gbs_api",
|
||||
"infoLink": "http://books.google.com/books?id=4iafgTEhU3QC&dq=isbn:9780143034759&hl=&source=gbs_api",
|
||||
"canonicalVolumeLink": "https://books.google.com/books/about/Alexander_Hamilton.html?hl=&id=4iafgTEhU3QC"
|
||||
},
|
||||
"saleInfo": {
|
||||
"country": "US",
|
||||
|
@ -55,18 +55,18 @@
|
|||
},
|
||||
"accessInfo": {
|
||||
"country": "US",
|
||||
"viewability": "NO_PAGES",
|
||||
"embeddable": false,
|
||||
"viewability": "PARTIAL",
|
||||
"embeddable": true,
|
||||
"publicDomain": false,
|
||||
"textToSpeechPermission": "ALLOWED",
|
||||
"textToSpeechPermission": "ALLOWED_FOR_ACCESSIBILITY",
|
||||
"epub": {
|
||||
"isAvailable": false
|
||||
},
|
||||
"pdf": {
|
||||
"isAvailable": false
|
||||
},
|
||||
"webReaderLink": "http://books.google.com/books/reader?id=y1_R-rjdcb0C&hl=&printsec=frontcover&output=reader&source=gbs_api",
|
||||
"accessViewStatus": "NONE",
|
||||
"webReaderLink": "http://play.google.com/books/reader?id=4iafgTEhU3QC&hl=&printsec=frontcover&source=gbs_api",
|
||||
"accessViewStatus": "SAMPLE",
|
||||
"quoteSharingAllowed": false
|
||||
},
|
||||
"searchInfo": {
|
||||
|
|
Loading…
Reference in New Issue