Commit inicial

Habia trabajado un buen poco pero como vi que tenia que separar los
repositorios perdi bastante la historia :c
This commit is contained in:
Daniel Cortes
2020-05-22 00:05:27 -04:00
commit 3568abfbc7
44 changed files with 1841 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__
venv
.idea
db.sqlite3

197
DOCS.md Normal file
View File

@@ -0,0 +1,197 @@
# Documentación API
## Comunicación con MusicBrainz
Todos los links a continuación se mapean aproximadamente a lo que indica la documentación de
[musicbrainz](https://musicbrainz.org/doc/Development/XML_Web_Service/Version_2) utilizando su formato
de JSON
### Get
- `GET` `/api/brainz/get/artist/<mbid>/`
Obtiene artista por su musicbrainz id, se puede incluir un parametro `inc` el cual agregara entidades
relacionados al artista, estos deben ser separados por símbolos `+`, las entidades disponibles son:
- `recordings`
- `releases`
- `release-groups`
Estas entidades relacionadas siempre estarán limitadas a 25.
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/get/artist/fa3b825f-7c85-4377-b393-d28a2016e293/?inc=releases+recordings
- `GET` `/api/brainz/get/release-group/<mbid>`
Obtiene release-group por su musicbrainz id, se puede incluir un parametro `inc` el cual agregara entidades
relacionados al release-group, estos deben ser separados por símbolos `+`, las entidades disponibles son:
- `artists`
- `releases`
Estas entidades relacionadas siempre estarán limitadas a 25.
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/get/release-group/86ac3ebd-2fee-49f7-a249-ddc6553970dd/?inc=releases
- `GET` `/api/brainz/get/release/<mbid>`
Obtiene release por su musicbrainz id, se puede incluir un parametro `inc` el cual agregara entidades
relacionados al release, estos deben ser separados por símbolos `+`, las entidades disponibles son:
- `artists`
- `release-groups`
- `recordings`
Estas entidades relacionadas siempre estarán limitadas a 25.
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/get/release/8c2b980a-dbc4-4047-b637-c470c29a6e05/?inc=recordings+artists
- `GET` `/api/brainz/get/recording/<mbid>`
Obtiene recording por su musicbrainz id, se puede incluir un parametro `inc` el cual agregara entidades
relacionados al recording, estos deben ser separados por símbolos `+`, las entidades disponibles son:
- `artists`
- `releases`
Estas entidades relacionadas siempre estarán limitadas a 25.
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/get/recording/051abadc-c78e-4fa1-a45e-1b24d6dab322/
### Search
- `GET` `/api/brainz/search/artist/`
Busca un artista en la base de datos de musicbrainz, los parámetros disponibles son los siguientes:
- `query`: Parámetro obligatorio el cual es un string con el que buscar
- `limit`: Parámetro opcional para limitar la cantidad de resultados, por default es 25
- `offset`: Parametro opcional para realizar paginado, por default es 0
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/search/artist/?query=in%20love%20with%20a%20ghost
- `GET` `/api/brainz/search/release-group/`
Busca un release-group en la base de datos de musicbrainz, los parámetros disponibles son los siguientes:
- `query`: Parametro obligatorio el cual es un string con el que buscar
- `limit`: Parametro opcional para limitar la cantidad de resultados, por default es 25
- `offset`: Parametro opcional para realizar paginado, por default es 0
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/search/release-group/?query=sizzlar&limit=1
- `GET` `/api/brainz/search/release/`
Busca un release en la base de datos de musicbrainz, los parámetros disponibles son los siguientes:
- `query`: Parametro obligatorio el cual es un string con el que buscar
- `limit`: Parametro opcional para limitar la cantidad de resultados, por default es 25
- `offset`: Parametro opcional para realizar paginado, por default es 0
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/search/release/?query=no&limit=10&offset=30
- `GET` `/api/brainz/search/recording/`
Busca un release en la base de datos de musicbrainz, los parámetros disponibles son los siguientes:
- `query`: Parametro obligatorio el cual es un string con el que buscar
- `limit`: Parametro opcional para limitar la cantidad de resultados, por default es 25
- `offset`: Parametro opcional para realizar paginado, por default es 0
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/search/recording/?query=no%20shade
### Browse
- `GET` `/api/brainz/browse/artist/`
Busca un artista dada una serie de parámetros, como por ejemplo, obtener un artista de la release
efac54db-1ff9-4ca4-a220-df2b155f3eb9. Sus parámetros son los siguientes
- `inc`: Parametro opcional para indicar los datos a incluir junto a los artistas encontrados, disponibles:
- `aliases`
- `limit`: Parametro opcional para indicar el limite de resultados, por default es 25
- `offstet`: Parametro opcional para realizar paginado, por default es 0
Las entidades con las que se puede buscar son las siguientes:
- `release-group`
- `release`
- `recording`
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/browse/artist/?recording=f059b12d-4379-4c4b-93eb-3a1f8026aa3b&release-group=37159cad-447e-4a71-bac4-9ef27e07d35b
- `GET` `/api/brainz/browse/release-group/`
Busca una release-group dada una serie de parámetros, como por ejemplo, obtener la release-group
de la release efac54db-1ff9-4ca4-a220-df2b155f3eb9. Sus parámetros son los siguientes
- `inc`: Parametro opcional para indicar los datos a incluir junto a las release-group encontradas, disponibles:
- `artist-credits`
- `limit`: Parametro opcional para indicar el limite de resultados, por default es 25
- `offstet`: Parametro opcional para realizar paginado, por default es 0
Las entidades con las que se puede buscar son las siguientes:
- `artist`
- `release`
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/browse/release-group/?release=67459555-dddd-3af4-9f45-b07ae5545caa
- `GET` `/api/brainz/browse/release/`
Busca una release dada una serie de parámetros, como por ejemplo, obtener las releases del artista
fa3b825f-7c85-4377-b393-d28a2016e293. Sus parámetros son los siguientes
- `inc`: Parametro opcional para indicar los datos a incluir junto a las release-group encontradas
- `limit`: Parametro opcional para indicar el limite de resultados, por default es 25
- `offstet`: Parametro opcional para realizar paginado, por default es 0
Las entidades con las que se puede buscar son las siguientes:
- `artist`
- `release-group`
- `recording`
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/browse/release/?artist=65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab
- `GET` `/api/brainz/browse/recording/`
Busca una recording dada una serie de parámetros, como por ejemplo, obtener las recordings del artista
fa3b825f-7c85-4377-b393-d28a2016e293. Sus parámetros son los siguientes
- `inc`: Parametro opcional para indicar los datos a incluir junto a las release-group encontradas
- `limit`: Parametro opcional para indicar el limite de resultados, por default es 25
- `offstet`: Parametro opcional para realizar paginado, por default es 0
Las entidades con las que se puede buscar son las siguientes:
- `artist`
- `release`
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/browse/recording/?artist=65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab&release=d2891587-1be3-3b43-b80e-d6afd960ad08
### Cover Art
- `GET` `/api/brainz/coverart/release-group/<mbid>/<int:size>/`
Busca el cover art de un release-group por su mbid, opcionalmente se le puede incluir el tamaño del cover art,
que puede ser 250, 500, 1200
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/coverart/release-group/325b927d-72df-4e71-a5f9-7c717f72fb42/
- `GET` `/api/brainz/coverart/release/<mbid>/<int:size>/`
Busca el cover art de una release por su mbid, opcionalmente se le puede incluir el tamaño del cover art,
que puede ser 250, 500, 1200
Ejemplo
https://musiclist.danielcortes.xyz/api/brainz/coverart/release/887ea51a-71de-4628-8f02-b061f6904a64/1200

0
fetcher/__init__.py Normal file
View File

5
fetcher/apps.py Normal file
View File

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

344
fetcher/musicbrainz.py Normal file
View File

@@ -0,0 +1,344 @@
import logging
import requests
from ratelimit import limits, sleep_and_retry
from urllib.parse import quote, urlencode
from utils import replace_key, sanitize_keys
from utils.cache import Cache as cache
_headers = {'accept': 'application/json', 'user-agent': 'MusicList/1.0 (danielcortes.xyz)'}
_mb_host = 'https://musicbrainz.org/ws/2'
_ca_host = 'http://coverartarchive.org'
_log = logging.getLogger('musicbrainz')
_log.addHandler(logging.NullHandler())
@sleep_and_retry
@limits(calls=1, period=1)
def _do_request(url, allow_redirects=True):
"""Does a request to a path and returns the dictionary representing the json response
If the response is 200 it will return the full dictionary of the json that the response
contains, and if is a 307 code, it will create a dictionary containing the response code
and the link where is redirecting, any other response code will be just appended to a
dictionary and returning.
:param str url: URL where to do the request
:return: The whole response object
:raises ValueError when user-agent isn't set
:raises ValueError when url isn't set
:returns A dictionary with the request response
it always has a status key with the status code by its value,
the rest of the response will be contained in the 'response' key
if the request didn't fail
"""
if not _headers['user-agent']:
raise ValueError('User Agent isn\'t set')
if not url:
raise ValueError('URL cant be empty')
_log.info(f'Doing request to "{url}" with headers {_headers}')
r = requests.get(url, headers=_headers, allow_redirects=allow_redirects)
_log.info(f'Request returned with status code {r.status_code}')
if r.status_code == 200:
response = r.json(object_hook=sanitize_keys)
elif r.status_code == 307:
response = {'link': r.headers['location']}
elif r.status_code == 404:
response = {'status': r.status_code, 'error': f'No encontrado'}
elif r.status_code == 400:
response = {'status': r.status_code, 'error': f'Query erronea'}
else:
response = {'status': r.status_code, 'error': f'Error desconocido de musicbrainz'}
return response
def _get(entity_type, mbid, includes=None):
"""Does a get entity query to musicbrainz
:param str entity_type: Type of the entity (artist, release, recording...)
:param str mbid: MBID of the entity to get
:param list includes: List of include parameters (recording, releases...)
:return: The JSON response
:rtype: dict
"""
if includes is None:
_log.info(f'Getting {entity_type} of mbid {mbid}')
return _do_request(f'{_mb_host}/{entity_type}/{mbid}')
else:
_log.info(f'Getting {entity_type} of mbid {mbid} including {includes}')
_includes = quote('+'.join(includes))
return _do_request(f'{_mb_host}/{entity_type}/{mbid}?inc={_includes}')
def _search(entity_type, query, limit=25, offset=0):
"""Does a search of an entity to musicbrainz
:param str entity_type: Type of the entity (artist, release, recording...)
:param str query: The search string
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: The JSON response
:rtype: dict
"""
_log.info(f'Searching {entity_type} with query "{query}" at offset {offset} with limit of {limit}')
if limit >= 0 and offset >= 0:
_query = urlencode({'query': query, 'limit': limit, 'offset': offset})
else:
_query = urlencode({'query': query})
return _do_request(f'{_mb_host}/{entity_type}/?{_query}')
def _browse(entity_type, params, includes=None, limit=25, offset=0):
"""Browses entities of a type with the given parameters
:param str entity_type: Type of the entity (artist, release, recording...)
:param dict params: Dictionary with the parameters to do the search
:param list includes: List of include parameters (recording, releases...)
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: The JSON response
:rtype: dict
:raises ValueError if params is not a dictionary
"""
if not isinstance(params, dict):
raise ValueError('Params must be a dictionary')
if includes is None:
_log.info(f'Browsing {entity_type} with parameters {params} at offset {offset} with limit of {limit}')
_query = urlencode({**params, 'limit': limit, 'offset': offset})
else:
_log.info(f'Browsing {entity_type} with parameters {params} including {includes} at offset {offset} with limit of {limit}')
_query = urlencode({**params, 'inc': '+'.join(includes), 'limit': limit, 'offset': offset})
return _do_request(f'{_mb_host}/{entity_type}?{_query}')
def _ca(entity_type, mbid, size=None):
"""Gets the url of the cover art of a release or release-group
:param str entity_type: Type of the entity, could be release or release-group
:param str mbid: MBID of the entity of whom is the cover art
:param int size: Optional size of the cover art, could be 250, 500 or 1200
any other value will be ignored
:return: The url of the cover art
"""
_log.info(f'Obtaining the cover art of the entity with type {entity_type} and mbid {mbid} with size {size}')
_url = f'{_ca_host}/{entity_type}/{mbid}/front'
if size in (250, 500, 1200):
_url += f'-{size}'
return _do_request(_url, allow_redirects=False)
@cache
def get_artist_by_mbid(mbid, includes=None):
"""Get an artist by its mbid
:param str mbid: MBID of the artist
:param list includes: List of include parameters
:return: dictionary with the response
"""
if includes is None:
includes = []
return _get('artist', mbid, includes)
@cache
def get_release_group_by_mbid(mbid, includes=None):
"""Get a release group by its mbid
:param str mbid: MBID of the release group
:param list includes: List of include parameters
:return: dictionary with the response
"""
if includes is None:
includes = []
return _get('release-group', mbid, includes)
@cache
def get_release_by_mbid(mbid, includes=None):
"""Get a release by its mbid
:param str mbid: MBID of the release
:param list includes: List of include parameters
:return: dictionary with the response
"""
if includes is None:
includes = []
return _get('release', mbid, includes)
@cache
def get_recording_by_mbid(mbid, includes=None):
"""Get a recording by its mbid
:param str mbid: MBID of the recording
:param list includes: List of include parameters
:return: dictionary with the response
"""
if includes is None:
includes = []
return _get('recording', mbid, includes)
@cache
def search_artist(query, limit=25, offset=0):
"""Search an artist by a query string
:param str query: Query string
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: dictionary with the response
"""
return _search('artist', query, limit, offset)
@cache
def search_release(query, limit=25, offset=0):
"""Search a release by a query string
:param str query: Query string
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: dictionary with the response
"""
return _search('release', query, limit, offset)
@cache
def search_release_group(query, limit=25, offset=0):
"""Search a release group by a query string
:param str query: Query string
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: dictionary with the response
"""
return _search('release-group', query, limit, offset)
@cache
def search_recording(query, limit=25, offset=0):
"""Search a recording by a query string
:param str query: Query string
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: dictionary with the response
"""
return _search('recording', query, limit, offset)
@cache
def browse_artists(params, includes=None, limit=25, offset=0):
"""Browse an artist given some params
:param dict params: Parameters to do a search
:param list includes: List of include parameters
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: dictionary with the response
"""
if includes is None:
includes = []
return _browse('artist', params, includes, limit, offset)
@cache
def browse_recordings(params, includes=None, limit=25, offset=0):
"""Browse through recordings given some params
:param dict params: Parameters to do a search
:param list includes: List of include parameters
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: dictionary with the response
"""
if includes is None:
includes = []
return _browse('recording', params, includes, limit, offset)
@cache
def browse_releases(params, includes=None, limit=25, offset=0):
"""Browse through releases given some params
:param dict params: Parameters to do a search
:param list includes: List of include parameters
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: dictionary with the response
"""
if includes is None:
includes = []
return _browse('release', params, includes, limit, offset)
@cache
def browse_release_groups(params, includes=None, limit=25, offset=0):
"""Browse through release groups given some params
:param dict params: Parameters to do a search
:param list includes: List of include parameters
:param int limit: Limit of the search, defaults to 25 and has a max of 100
:param int offset: Offset of the search for paging purposes
:return: dictionary with the response
"""
if includes is None:
includes = []
return _browse('release-group', params, includes, limit, offset)
@cache
def get_release_cover_art(mbid, size=None):
"""Gets the url of the cover art of a release
:param str mbid: MBID of the release of whom is the cover art
:param int size: Optional size of the cover art, could be 250, 500 or 1200
:return: dictionary with the response
"""
return _ca('release', mbid, size)
@cache
def get_release_group_cover_art(mbid, size=None):
"""Gets the url of the cover art of a release group
:param str mbid: MBID of the release group of whom is the cover art
:param int size: Optional size of the cover art, could be 250, 500 or 1200
:return: dictionary with the response
"""
return _ca('release-group', mbid, size)

44
fetcher/tests.py Normal file
View File

@@ -0,0 +1,44 @@
import redis
from django.test import TestCase
from fetcher import musicbrainz
class MusicBrainzTestCase(TestCase):
def setUp(self):
redis.Redis().flushall()
def test_can_do_basic_request(self):
artist_url = 'https://musicbrainz.org/ws/2/artist/65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab'
musicbrainz._do_request(artist_url)
def test_do_request_response_format(self):
artist_url = 'https://musicbrainz.org/ws/2/artist/65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab'
response = musicbrainz._do_request(artist_url)
self.assertIsInstance(response, dict)
self.assertEquals(response['status'], 200)
self.assertIsInstance(response['response'], dict)
def test_do_request_input_validation(self):
artist_url = 'https://musicbrainz.org/ws/2/artist/65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab'
self.assertRaises(ValueError, musicbrainz._do_request, None)
self.assertRaises(ValueError, musicbrainz._do_request, '')
musicbrainz._headers['user-agent'] = None
self.assertRaises(ValueError, musicbrainz._do_request, artist_url)
def test_cache(self):
def fun():
return {}
musicbrainz.cache(fun)()
self.assertTrue(redis.Redis().keys('*') is not None)
self.assertEquals(musicbrainz.cache(fun)(), {})
redis.Redis().set('fun, [[], {}]', '{"test": true}')
self.assertEquals(musicbrainz.cache(fun)(), {"test": True})
def fun(a, b):
return {}
musicbrainz.cache(fun)('word', [''])

24
fetcher/urls.py Normal file
View File

@@ -0,0 +1,24 @@
from django.urls import path
from . import views
urlpatterns = [
path('get/artist/<mbid>/', views.get_artist_by_mbid),
path('get/release-group/<mbid>/', views.get_release_group_by_mbid),
path('get/release/<mbid>/', views.get_release_by_mbid),
path('get/recording/<mbid>/', views.get_recording_by_mbid),
path('search/artist/', views.search_artist),
path('search/release-group/', views.search_release_group),
path('search/release/', views.search_release),
path('search/recording/', views.search_recording),
path('browse/artist/', views.browse_artists),
path('browse/release-group/', views.browse_release_groups),
path('browse/release/', views.browse_releases),
path('browse/recording/', views.browse_recordings),
path('coverart/release-group/<mbid>/<int:size>/', views.get_release_group_cover_art),
path('coverart/release-group/<mbid>/', views.get_release_group_cover_art),
path('coverart/release/<mbid>/<int:size>', views.get_release_cover_art),
path('coverart/release/<mbid>/', views.get_release_group_cover_art),
]

127
fetcher/views.py Normal file
View File

@@ -0,0 +1,127 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from . import musicbrainz as mb
def _get_by_mbid(request, entity_type, mbid):
includes = request.GET.get('inc', '').split('+')
if entity_type == 'artist':
response = mb.get_artist_by_mbid(mbid, includes)
elif entity_type == 'release-group':
response = mb.get_release_group_by_mbid(mbid, includes)
elif entity_type == 'release':
response = mb.get_release_by_mbid(mbid, includes)
elif entity_type == 'recording':
response = mb.get_recording_by_mbid(mbid, includes)
else:
raise ValueError('Entity Type isn\'t valid')
if 'status' in response:
return Response(response, status=response['status'])
else:
return Response(response)
def _search(request, entity_type):
query = request.GET.get('query', '')
limit = request.GET.get('limit', 25)
offset = request.GET.get('offset', 0)
if entity_type == 'artist':
response = mb.search_artist(query, limit, offset)
elif entity_type == 'release-group':
response = mb.search_release_group(query, limit, offset)
elif entity_type == 'release':
response = mb.search_release(query, limit, offset)
elif entity_type == 'recording':
response = mb.search_recording(query, limit, offset)
else:
raise ValueError('Entity Type isn\'t valid')
return Response(response)
def _browse(request, entity_type):
includes = request.GET.get('inc', '').split('+')
limit = request.GET.get('limit', 25)
offset = request.GET.get('offset', 0)
params = request.GET.copy().dict()
if 'inc' in params:
del params['inc']
if 'limit' in params:
del params['limit']
if 'offset' in params:
del params['offset']
if entity_type == 'artist':
response = mb.browse_artists(params, includes, limit, offset)
elif entity_type == 'release-group':
response = mb.browse_release_groups(params, includes, limit, offset)
elif entity_type == 'release':
response = mb.browse_releases(params, includes, limit, offset)
elif entity_type == 'recording':
response = mb.browse_recordings(params, includes, limit, offset)
else:
raise ValueError('Entity Type isn\'t valid')
return Response(response)
@api_view(['GET'])
def get_artist_by_mbid(request, mbid): return _get_by_mbid(request, 'artist', mbid)
@api_view(['GET'])
def get_release_group_by_mbid(request, mbid): return _get_by_mbid(request, 'release-group', mbid)
@api_view(['GET'])
def get_release_by_mbid(request, mbid): return _get_by_mbid(request, 'release', mbid)
@api_view(['GET'])
def get_recording_by_mbid(request, mbid): return _get_by_mbid(request, 'recording', mbid)
@api_view(['GET'])
def search_artist(request): return _search(request, 'artist')
@api_view(['GET'])
def search_release_group(request): return _search(request, 'release-group')
@api_view(['GET'])
def search_release(request): return _search(request, 'release')
@api_view(['GET'])
def search_recording(request): return _search(request, 'recording')
@api_view(['GET'])
def browse_artists(request): return _browse(request, 'artist')
@api_view(['GET'])
def browse_release_groups(request): return _browse(request, 'release-group')
@api_view(['GET'])
def browse_releases(request): return _browse(request, 'release')
@api_view(['GET'])
def browse_recordings(request): return _browse(request, 'recording')
@api_view(['GET'])
def get_release_cover_art(request, mbid, size=None): return Response(mb.get_release_cover_art(mbid, size))
@api_view(['GET'])
def get_release_group_cover_art(request, mbid, size=None):
return Response(mb.get_release_group_cover_art(mbid, size))

0
lists/__init__.py Normal file
View File

3
lists/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
lists/apps.py Normal file
View File

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

View File

51
lists/models.py Normal file
View File

@@ -0,0 +1,51 @@
from django.contrib.auth.models import User
from django.db.models import CharField, TextField, IntegerField
from django.db.models import ForeignKey, ManyToManyField
from django.db.models import CASCADE
from django.db.models import Model
class Entity(Model):
ENTITY_TYPES = [
('artist', 'Artista'),
('release-group', 'Grupo de Lanzamientos'),
('release', 'Lanzamiento'),
('recording', 'Grabación')
]
mbid = CharField(max_length=36, primary_key=True)
entity_type = CharField(max_length=25, choices=ENTITY_TYPES)
class Tag(Model):
user = ForeignKey(User, on_delete=CASCADE)
name = CharField(max_length=50)
class ListItem(Model):
user = ForeignKey(User, on_delete=CASCADE)
entity = ForeignKey(Entity, on_delete=CASCADE)
tags = ManyToManyField(Tag, on_delete=CASCADE)
class Stars(Model):
user = ForeignKey(User, on_delete=CASCADE)
entity = ForeignKey(ListItem, on_delete=CASCADE)
quantity = IntegerField()
class Opinion(Model):
user = ForeignKey(User, on_delete=CASCADE)
entity = ForeignKey(ListItem, on_delete=CASCADE)
opinion_text = TextField()
class OpinionHelpful(Model):
VOTES = [
('Y', 'Si'),
('N', 'No'),
('F', 'Divertida')
]
user = ForeignKey(User, on_delete=CASCADE)
opinion = ForeignKey(Opinion, on_delete=CASCADE)
vote = CharField(max_length=1, choices=VOTES)

3
lists/tests.py Normal file
View File

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

3
lists/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

21
manage.py Executable file
View File

@@ -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', 'musiclist.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
musiclist/__init__.py Normal file
View File

16
musiclist/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for musiclist project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'musiclist.settings')
application = get_asgi_application()

172
musiclist/settings.py Normal file
View File

@@ -0,0 +1,172 @@
"""
Django settings for musiclist project.
Generated by 'django-admin startproject' using Django 3.0.5.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# noinspection SpellCheckingInspection
SECRET_KEY = 'c8%4_^4oc%wwqlcxlon-(_7v!xj8fbyba+pj*xy$oi*6#n!7ez'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
CORS_ORIGIN_ALLOW_ALL = True
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'oauth2_provider',
'corsheaders',
'rest_framework',
'fetcher.apps.FetcherConfig',
'users.apps.UsersConfig',
'welcome.apps.WelcomeConfig',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'musiclist.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'musiclist.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny',
)
}
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = 'es-es'
TIME_ZONE = 'America/Santiago'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/static/'
LOGGING_LEVEL = 'DEBUG'
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"coloredlogs": {
"()": "coloredlogs.ColoredFormatter",
"fmt": "[%(asctime)s] %(name)s %(levelname)s %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": LOGGING_LEVEL,
"formatter": "coloredlogs",
},
},
"filters": {
"hostname": {
"()": "coloredlogs.HostNameFilter",
},
},
"loggers": {
"": {
"handlers": ["console"],
"level": LOGGING_LEVEL
},
}
}
CUSTOM_CACHE = {
'enabled': True
}
LOGIN_URL = '/auth/login/'
AUTH_USER_MODEL = 'users.User'
CORS_ORIGIN_ALLOW_ALL = True

10
musiclist/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('', include('welcome.urls')),
path('admin/', admin.site.urls),
path('oauth/', include('oauth2_provider.urls', namespace='oauth2_provider')),
path('auth/', include('users.urls')),
path('api/brainz/', include('fetcher.urls')),
]

16
musiclist/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for musiclist project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'musiclist.settings')
application = get_wsgi_application()

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
django
django-cors-middleware
django-oauth-toolkit
djangorestframework
coloredlogs
pygments
ratelimit
redis
requests
sphinx
blake3

0
users/__init__.py Normal file
View File

88
users/admin.py Normal file
View File

@@ -0,0 +1,88 @@
from django.contrib import admin
from django.contrib.auth.forms import ReadOnlyPasswordHashField
from django.contrib.auth.models import Group
from django import forms
from users.models import User, SocialNetworks
admin.site.unregister(Group)
class UserAddForm(forms.ModelForm):
password = forms.CharField(label='Contraseña', widget=forms.PasswordInput)
password_confirmation = forms.CharField(label='Confirmación de contraseña', widget=forms.PasswordInput)
class Meta:
model = User
fields = ('username', 'email')
def clean_password_confirmation(self):
password = self.cleaned_data.get("password")
password_confirmation = self.cleaned_data.get("password_confirmation")
if password and password_confirmation and password != password_confirmation:
raise forms.ValidationError('Las contraseñas no coinciden')
return password_confirmation
def save(self, commit=True):
user = super().save(commit=False)
user.set_password(self.cleaned_data['password'])
if commit:
user.save()
return user
class UserChangeForm(forms.ModelForm):
password = ReadOnlyPasswordHashField()
class Meta:
model = User
fields = ('username', 'email', 'password', 'is_admin')
def clean_password(self):
return self.initial['password']
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
form = UserChangeForm
add_form = UserAddForm
list_display = ('username', 'email', 'is_admin', 'is_active')
list_filter = ('is_admin', 'is_active')
fieldsets = (
(None, {'fields': ('username', 'email', 'password')}),
('Permisos', {'fields': ('is_admin',)}),
)
add_fieldsets = (
(None, {'fields': ('username', 'email', 'password', 'password_confirmation')}),
)
search_fields = ('username', 'email')
ordering = ('username', 'email')
def get_fieldsets(self, request, obj=None):
if not obj:
return self.add_fieldsets
return super().get_fieldsets(request, obj)
def get_form(self, request, obj=None, **kwargs):
defaults = {}
if obj is None:
defaults['form'] = self.add_form
defaults.update(kwargs)
return super().get_form(request, obj, **defaults)
@admin.register(SocialNetworks)
class SocialNetworksAdmin(admin.ModelAdmin):
list_display = ('user', 'twitter', 'facebook', 'instagram', 'youtube', 'twitch')
raw_id_fields = ('user',)
search_fields = ('user__username',)
ordering = ('user',)

5
users/apps.py Normal file
View File

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

View File

@@ -0,0 +1,34 @@
# Generated by Django 3.0.6 on 2020-05-16 02:58
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.manager
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('username', models.CharField(max_length=40, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()])),
('email', models.EmailField(max_length=254)),
('is_active', models.BooleanField(default=True)),
('is_admin', models.BooleanField(default=False)),
],
options={
'abstract': False,
},
managers=[
('object', django.db.models.manager.Manager()),
],
),
]

View File

@@ -0,0 +1,56 @@
# Generated by Django 3.0.6 on 2020-05-16 06:01
from django.conf import settings
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SocialNetworks',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
('twitter', models.CharField(max_length=255, verbose_name='twitter')),
('facebook', models.CharField(max_length=255, verbose_name='facebook')),
('instagram', models.CharField(max_length=255, verbose_name='instagram')),
('youtube', models.CharField(max_length=255, verbose_name='youtube')),
('twitch', models.CharField(max_length=255, verbose_name='twitch')),
],
),
migrations.AlterModelOptions(
name='user',
options={'verbose_name': 'usuario', 'verbose_name_plural': 'usuarios'},
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, verbose_name='correo'),
),
migrations.AlterField(
model_name='user',
name='is_active',
field=models.BooleanField(default=True, verbose_name='esta activo'),
),
migrations.AlterField(
model_name='user',
name='is_admin',
field=models.BooleanField(default=False, verbose_name='es administrador'),
),
migrations.AlterField(
model_name='user',
name='password',
field=models.CharField(max_length=128, verbose_name='contraseña'),
),
migrations.AlterField(
model_name='user',
name='username',
field=models.CharField(max_length=40, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='nombre de usuario'),
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 3.0.6 on 2020-05-16 06:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0002_auto_20200516_0201'),
]
operations = [
migrations.AlterModelOptions(
name='socialnetworks',
options={'verbose_name': 'Redes Sociales', 'verbose_name_plural': 'Redes Sociales'},
),
migrations.AlterField(
model_name='socialnetworks',
name='facebook',
field=models.CharField(blank=True, max_length=255, verbose_name='facebook'),
),
migrations.AlterField(
model_name='socialnetworks',
name='instagram',
field=models.CharField(blank=True, max_length=255, verbose_name='instagram'),
),
migrations.AlterField(
model_name='socialnetworks',
name='twitch',
field=models.CharField(blank=True, max_length=255, verbose_name='twitch'),
),
migrations.AlterField(
model_name='socialnetworks',
name='twitter',
field=models.CharField(blank=True, max_length=255, verbose_name='twitter'),
),
migrations.AlterField(
model_name='socialnetworks',
name='youtube',
field=models.CharField(blank=True, max_length=255, verbose_name='youtube'),
),
]

78
users/models.py Normal file
View File

@@ -0,0 +1,78 @@
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.db import models
class UserManager(BaseUserManager):
def create_user(self, username, email, password):
if not username:
raise ValueError('El usuario debe tener un nombre de usuario')
if not email:
raise ValueError('El usuario debe tener un email')
if not password:
raise ValueError('El usuario debe tener una contraseña')
user = self.model(
username=self.model.normalize_username(username),
email=self.normalize_email(email),
)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, username, email, password=None):
user = self.create_user(
username=username,
email=email,
password=password,
)
user.is_admin = True
user.save(using=self._db)
return user
class User(AbstractBaseUser):
class Meta:
verbose_name = 'usuario'
verbose_name_plural = 'usuarios'
object = UserManager()
username_validator = UnicodeUsernameValidator()
username = models.CharField('nombre de usuario', max_length=40, validators=[username_validator], unique=True)
email = models.EmailField('correo')
is_active = models.BooleanField('esta activo', default=True)
is_admin = models.BooleanField('es administrador', default=False)
password = models.CharField('contraseña', max_length=128)
USERNAME_FIELD = 'username'
EMAIL_FIELD = 'email'
REQUIRED_FIELDS = ['email']
def has_perm(self, *args, **kwargs):
return True
def has_module_perms(self, *args, **kwargs):
return True
@property
def is_staff(self):
return self.is_admin
class SocialNetworks(models.Model):
class Meta:
verbose_name = 'Redes Sociales'
verbose_name_plural = 'Redes Sociales'
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
twitter = models.CharField('twitter', max_length=255, blank=True)
facebook = models.CharField('facebook', max_length=255, blank=True)
instagram = models.CharField('instagram', max_length=255, blank=True)
youtube = models.CharField('youtube', max_length=255, blank=True)
twitch = models.CharField('twitch', max_length=255, blank=True)
def __str__(self):
return f'{self._meta.verbose_name_plural} de {self.user.username}'

View File

@@ -0,0 +1,88 @@
:root {
--background-color: hsl(10, 20%, 98%);
--foreground-color: hsl(10, 10%, 13%);
--primary-color: hsl(284, 30%, 40%);
--highlight-color: hsl(290, 86%, 43%);
--light-color: hsl(10, 10%, 40%);
--error-color: hsl(0, 100%, 60%)
}
body {
background-color: var(--background-color);
color: var(--foreground-color);
overflow-y: scroll;
font-family: sans-serif;
}
a {
color: var(--highlight-color)
}
.login-box {
margin: 100px auto;
width: 45ch;
border: 1px solid #eaeaea;
border-radius: 4px;
overflow: hidden;
}
.login-box .heading {
display: flex;
align-items: center;
background: var(--primary-color);
color: var(--background-color);
height: 3em;
justify-content: center;
}
.login-box .form {
background-color: white;
padding: 20px;
}
.login-box .form a {
display: inline-block;
margin-top: 1em;
}
.error {
background-color: var(--error-color);
border: 1px rgba(234, 234, 234, .4) solid;
border-radius: 4px;
margin-bottom: 1em;
}
.error p {
text-align: center;
font-weight: bold;
color: var(--background-color);
}
label {
display: block;
margin-bottom: .3em;
font-size: .9em;
}
input[type='text'], input[type='password'], input[type='email'] {
display: block;
padding: 8px;
width: 100%;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
margin-bottom: 1.3em;
font-size: .9em;
}
.submit {
display: block;
width: 10ch;
height: 2.3em;
margin: .4em auto;
border: 1px solid rgba(234, 234, 234, .3);
border-radius: 8px;
color: var(--background-color);
background-color: var(--primary-color);
font-size: .9em;
}

4
users/static/users/css/normalize.css vendored Normal file
View File

@@ -0,0 +1,4 @@
/******************************************************************************
=> NORMALIZE.CSS
*******************************************************************************/
button,hr,input{overflow:visible}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}details,main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none}

View File

@@ -0,0 +1,20 @@
{% load static %}
<!DOCTYPE html>
<html lang="es">
<head>
<title>{% block title %}MusicList{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{% static "users/css/normalize.css" %}">
<link rel="stylesheet" type="text/css" href="{% static "users/css/base.css" %}">
{% block extrastyle %}{% endblock %}
{% block extrahead %}{% endblock %}
{% block responsive %}
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0, maximum-scale=1.0">
{% endblock %}
{% block blockbots %}<meta name="robots" content="NONE,NOARCHIVE">{% endblock %}
</head>
<body>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{% extends "users/base.html" %}
{% block content %}
<div class="login-box">
<div class="heading">
<p>MusicList Login</p>
</div>
<div class="form">
{% if error %}
<div class="error">
<p>{{ error }}</p>
</div>
{% endif %}
<form method="POST" action="{% url 'auth:login' %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<label for="username">Usuario:</label>
<input type="text" name="username" id="username" required>
<label for="password">Contraseña:</label>
<input type="password" name="password" id="password" required>
<button class="submit" type="submit">Entrar</button>
</form>
<a href="{% url 'auth:register' %}?next={{ next|urlencode }}">¿No tienes cuenta?</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "users/base.html" %}
{% block content %}
<div class="login-box">
<div class="heading">
<p>MusicList Registration</p>
</div>
<div class="form">
{% if error %}
<div class="error">
<p>{{ error }}</p>
</div>
{% endif %}
<form method="POST" action="{% url 'auth:register' %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<label for="username">Usuario:</label>
<input type="text" name="username" id="username" {% if old.username %} value="{{ old.username }}" {% endif%} required>
<label for="email">Email:</label>
<input type="email" name="email" id="email" {% if old.email %} value="{{ old.email}}" {% endif%}required>
<label for="password">Contraseña:</label>
<input type="password" name="password" id="password" required>
<label for="password_confirm">Confirme Contraseña:</label>
<input type="password" name="password_confirm" id="password_confirm" required>
<button class="submit" type="submit">Entrar</button>
</form>
</div>
</div>
{% endblock %}

10
users/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from users import views
app_name = 'auth'
urlpatterns = [
path('login/', views.login, name='login'),
path('logout/', views.logout, name='logout'),
path('register/', views.register, name='register')
]

102
users/views.py Normal file
View File

@@ -0,0 +1,102 @@
from django.http import HttpResponseRedirect, HttpResponseNotAllowed
from django.shortcuts import render
from django.utils.http import url_has_allowed_host_and_scheme
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout, get_user_model
def get_next_url(request):
next_url = request.POST.get('next', request.GET.get('next', ''))
url_is_safe = url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts=request.get_host(),
require_https=request.is_secure(),
)
print(next_url if url_is_safe else '/')
return next_url if url_is_safe else '/'
def login(request):
if request.method == 'GET':
return _login_get(request)
elif request.method == 'POST':
return _login_post(request)
else:
return HttpResponseNotAllowed(permitted_methods=['GET', 'POST'])
def _login_get(request):
return render(request, template_name='users/login.html', context={'next': get_next_url(request)})
def _login_post(request):
username = request.POST.get('username', '')
password = request.POST.get('password', '')
user = authenticate(request, username=username, password=password)
if user is not None:
auth_login(request, user)
return HttpResponseRedirect(get_next_url(request))
else:
return render(
request,
template_name='users/login.html',
context={'next': get_next_url(request), 'error': 'Usuario o contraseña son incorrectos'}
)
def logout(request):
auth_logout(request)
return HttpResponseRedirect(get_next_url(request))
def register(request):
if request.method == 'GET':
return _register_get(request)
elif request.method == 'POST':
return _register_post(request)
else:
return HttpResponseNotAllowed(permitted_methods=['GET', 'POST'])
def _register_get(request):
return render(request, template_name='users/register.html', context={'next': get_next_url(request)})
def _register_post(request):
username = request.POST.get('username', '')
password = request.POST.get('password', '')
password_confirm = request.POST.get('password_confirm', '')
email = request.POST.get('email', '')
old = {
'username': username,
'email': email
}
if not username:
return render(
request,
template_name='users/register.html',
context={'next': get_next_url(request), 'error': 'Debe ingresar un nombre de usuario', 'old': old}
)
if get_user_model().objects.filter(username=username).count() > 0:
return render(
request,
template_name='users/register.html',
context={'next': get_next_url(request), 'error': 'El nombre de usuario esta en uso', 'old': old}
)
if password and password != password_confirm:
return render(
request,
template_name='users/register.html',
context={'next': get_next_url(request), 'error': 'Las contraseñas no coinciden', 'old': old}
)
user = get_user_model().objects.create_user(username, email, password)
auth_login(request, user)
return HttpResponseRedirect(get_next_url(request))

71
utils/__init__.py Normal file
View File

@@ -0,0 +1,71 @@
import logging
from collections import Mapping
_log = logging.getLogger('utils')
_log.addHandler(logging.NullHandler())
def pretty_print_json(json_input):
"""Formats and prints json to stdout with colors using pygments"""
import json
from pygments import highlight
from pygments.lexers import JsonLexer
from pygments.formatters import TerminalTrueColorFormatter
formatted_json = json.dumps(json_input, indent=2)
print(highlight(formatted_json, JsonLexer(), TerminalTrueColorFormatter()))
def message_response(status, error_message):
"""Sends an error response with the status code of the error and an explanation"""
from django.http import JsonResponse
json_message = {
'status_code': status,
'message': error_message
}
return JsonResponse(json_message, status=status)
# noinspection PyPep8Naming
def require_JSON(function):
"""Decorator to make a view only accept a json body"""
import functools
import json
@functools.wraps(function)
def decorator(*args, **kwargs):
try:
received_json = json.loads(args[0].body)
return function(*args, **kwargs, received_json=received_json)
except json.JSONDecodeError as error:
_log.warning(f'Function {function.__name__} got a non json request body')
return message_response(400, 'Se envío json no valido')
return decorator
def expected_keys(keys, dictionary):
for key in keys:
if key not in dictionary:
return f'No se encuentra {key}'
return None
def replace_key(json, old, new):
json[new] = json[old]
del json[old]
def sanitize_keys(json):
for key in list(json.keys()):
if '-' in key and key in json:
new_key = key.replace('-', '_')
replace_key(json, key, new_key)
return json

54
utils/cache.py Normal file
View File

@@ -0,0 +1,54 @@
import json
import logging
from blake3 import blake3
from redis import Redis
from django.conf import settings
_log = logging.getLogger('cache')
_log.addHandler(logging.NullHandler())
_redis = Redis()
class Cache:
""" Decorator that caches the result of a function
It works by generating a key given the function signature and uses it to store the result
of the function on redis, so if the function is called with the same parameters again it
will be cached and will retrieve the data from redis and will not execute the function.
It is assumed that the function will returns a dictionary that can be formatted to json.
"""
"""If caching is enabled"""
enabled = settings.CUSTOM_CACHE['enabled']
"""Time to expire, by default a week"""
expire = 60 * 60 * 24 * 7
def __init__(self, function):
self.function = function
def __call__(self, *args, **kwargs):
if not self.enabled:
_log.info('Cache is disabled, executing function directly')
return self.function(*args, **kwargs)
_log.info(f'Caching function {self.function.__name__} with argument list {args} and dictionary {kwargs}')
key = f'{self.function.__name__}:{self.create_hash_key(args, kwargs)}'
_log.debug(f'Resolved key for function is "{key}"')
if _redis.exists(key):
_log.info(f'Key was in cache')
result = json.loads(_redis.get(key))
return result
else:
_log.info('Key was not in cache')
result = self.function(*args, **kwargs)
_redis.set(key, json.dumps(result), ex=self.expire)
return result
@staticmethod
def create_hash_key(args, kwargs):
return blake3((str(args) + str(kwargs)).encode('utf-8')).hexdigest()

0
welcome/__init__.py Normal file
View File

5
welcome/apps.py Normal file
View File

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

View File

@@ -0,0 +1,29 @@
:root {
--background-color: hsl(10, 20%, 98%);
--foreground-color: hsl(10, 10%, 13%);
--primary-color: hsl(284, 30%, 40%);
--highlight-color: hsl(290, 86%, 43%);
--light-color: hsl(10, 10%, 40%);
--error-color: hsl(0, 100%, 60%)
}
html, body {
height: 100%;
}
body {
background-color: var(--background-color);
color: var(--foreground-color);
overflow-y: scroll;
font-family: sans-serif;
}
main {
max-width: 80ch;
margin: 25vh auto;
}
a {
color: var(--highlight-color)
}

View File

@@ -0,0 +1,4 @@
/******************************************************************************
=> NORMALIZE.CSS
*******************************************************************************/
button,hr,input{overflow:visible}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}details,main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none}

View File

@@ -0,0 +1,22 @@
{% load static %}
<!DOCTYPE html>
<html lang="es">
<head>
<title>{% block title %}MusicList{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{% static "welcome/css/normalize.css" %}">
<link rel="stylesheet" type="text/css" href="{% static "welcome/css/base.css" %}">
{% block extrastyle %}{% endblock %}
{% block extrahead %}{% endblock %}
{% block responsive %}
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0, maximum-scale=1.0">
{% endblock %}
</head>
<body>
<main>
<h1>Hey no deberías estar aquí!</h1>
<p>Esto esta en desarrollo, esta es la api para una sexy aplicación con la que se podrá organizar musica
muy al estilo de <a href="https://www.myanimelist.net">MyAnimeList</a> pero claro, con musica.</p>
<p>Algún dia estará terminada :3</p>
</main>
</body>
</html>

11
welcome/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.shortcuts import render
from django.urls import path
def index(request):
return render(request, 'welcome/index.html')
urlpatterns = [
path('', index)
]