forked from s3lph/matemat
Merge branch 'python-3.7' into 'staging'
Python 3.7 See merge request s3lph/matemat!26
This commit is contained in:
commit
0daf37d94a
10 changed files with 66 additions and 178 deletions
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
image: s3lph/matemat-ci:20180720-01
|
image: s3lph/matemat-ci:20180802-02
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- test
|
- test
|
||||||
|
@ -10,8 +10,8 @@ test:
|
||||||
stage: test
|
stage: test
|
||||||
script:
|
script:
|
||||||
- pip3 install -r requirements.txt
|
- pip3 install -r requirements.txt
|
||||||
- sudo -u matemat python3-coverage run --branch -m unittest discover matemat
|
- sudo -u matemat python3 -m coverage run --branch -m unittest discover matemat
|
||||||
- sudo -u matemat python3-coverage report -m --include 'matemat/*' --omit '*/test_*.py'
|
- sudo -u matemat python3 -m coverage report -m --include 'matemat/*' --omit '*/test_*.py'
|
||||||
|
|
||||||
codestyle:
|
codestyle:
|
||||||
stage: test
|
stage: test
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
FROM python:3.6-alpine
|
FROM python:3.7-alpine
|
||||||
|
|
||||||
RUN mkdir -p /var/matemat/db /var/matemat/upload
|
RUN mkdir -p /var/matemat/db /var/matemat/upload
|
||||||
RUN apk --update add libmagic
|
RUN apk --update add libmagic
|
||||||
|
|
|
@ -16,7 +16,7 @@ This project intends to provide a well-tested and maintainable alternative to
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- Python 3 (>=3.6)
|
- Python 3 (>=3.7)
|
||||||
- Python dependencies:
|
- Python dependencies:
|
||||||
- file-magic
|
- file-magic
|
||||||
- jinja2
|
- jinja2
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
from typing import List, Optional, Any, Type
|
from typing import List, Optional, Any, Type
|
||||||
|
|
||||||
import crypt
|
import crypt
|
||||||
|
@ -9,12 +10,6 @@ from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
|
||||||
from matemat.db import DatabaseWrapper
|
from matemat.db import DatabaseWrapper
|
||||||
from matemat.db.wrapper import Transaction
|
from matemat.db.wrapper import Transaction
|
||||||
|
|
||||||
# TODO: Change to METHOD_BLOWFISH when adopting Python 3.7
|
|
||||||
"""
|
|
||||||
The method to use for password hashing.
|
|
||||||
"""
|
|
||||||
_CRYPT_METHOD = crypt.METHOD_SHA512
|
|
||||||
|
|
||||||
|
|
||||||
class MatematDatabase(object):
|
class MatematDatabase(object):
|
||||||
"""
|
"""
|
||||||
|
@ -42,7 +37,7 @@ class MatematDatabase(object):
|
||||||
"""
|
"""
|
||||||
self.db: DatabaseWrapper = DatabaseWrapper(filename)
|
self.db: DatabaseWrapper = DatabaseWrapper(filename)
|
||||||
|
|
||||||
def __enter__(self) -> 'MatematDatabase':
|
def __enter__(self) -> MatematDatabase:
|
||||||
# Pass context manager stuff through to the database wrapper
|
# Pass context manager stuff through to the database wrapper
|
||||||
self.db.__enter__()
|
self.db.__enter__()
|
||||||
return self
|
return self
|
||||||
|
@ -116,7 +111,7 @@ class MatematDatabase(object):
|
||||||
:raises ValueError: If a user with the same name already exists.
|
:raises ValueError: If a user with the same name already exists.
|
||||||
"""
|
"""
|
||||||
# Hash the password.
|
# Hash the password.
|
||||||
pwhash: str = crypt.crypt(password, crypt.mksalt(_CRYPT_METHOD))
|
pwhash: str = crypt.crypt(password, crypt.mksalt())
|
||||||
user_id: int = -1
|
user_id: int = -1
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
# Look up whether a user with the same name already exists.
|
# Look up whether a user with the same name already exists.
|
||||||
|
@ -194,7 +189,7 @@ class MatematDatabase(object):
|
||||||
if verify_password and not compare_digest(crypt.crypt(oldpass, row[0]), row[0]):
|
if verify_password and not compare_digest(crypt.crypt(oldpass, row[0]), row[0]):
|
||||||
raise AuthenticationError('Old password does not match.')
|
raise AuthenticationError('Old password does not match.')
|
||||||
# Hash the new password and write it to the database.
|
# Hash the new password and write it to the database.
|
||||||
pwhash: str = crypt.crypt(newpass, crypt.mksalt(_CRYPT_METHOD))
|
pwhash: str = crypt.crypt(newpass, crypt.mksalt())
|
||||||
c.execute('''
|
c.execute('''
|
||||||
UPDATE users SET password = :pwhash, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id
|
UPDATE users SET password = :pwhash, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id
|
||||||
''', {
|
''', {
|
||||||
|
@ -224,7 +219,7 @@ class MatematDatabase(object):
|
||||||
if verify_password and not compare_digest(crypt.crypt(password, row[0]), row[0]):
|
if verify_password and not compare_digest(crypt.crypt(password, row[0]), row[0]):
|
||||||
raise AuthenticationError('Password does not match.')
|
raise AuthenticationError('Password does not match.')
|
||||||
# Hash the new touchkey and write it to the database.
|
# Hash the new touchkey and write it to the database.
|
||||||
tkhash: Optional[str] = crypt.crypt(touchkey, crypt.mksalt(_CRYPT_METHOD)) if touchkey is not None else None
|
tkhash: Optional[str] = crypt.crypt(touchkey, crypt.mksalt()) if touchkey is not None else None
|
||||||
c.execute('''
|
c.execute('''
|
||||||
UPDATE users SET touchkey = :tkhash, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id
|
UPDATE users SET touchkey = :tkhash, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id
|
||||||
''', {
|
''', {
|
||||||
|
|
|
@ -1,75 +1,22 @@
|
||||||
|
|
||||||
from typing import Any
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
class Product(object):
|
@dataclass
|
||||||
|
class Product:
|
||||||
"""
|
"""
|
||||||
Representation of a product offered by the Matemat, with a name, prices for users, and the number of items
|
Representation of a product offered by the Matemat, with a name, prices for users, and the number of items
|
||||||
currently in stock.
|
currently in stock.
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self,
|
:param id: The product ID in the database.
|
||||||
product_id: int,
|
|
||||||
name: str,
|
|
||||||
price_member: int,
|
|
||||||
price_non_member: int,
|
|
||||||
stock: int) -> None:
|
|
||||||
"""
|
|
||||||
Create a new product instance with the given arguments.
|
|
||||||
|
|
||||||
:param product_id: The product ID in the database.
|
|
||||||
:param name: The product's name.
|
:param name: The product's name.
|
||||||
:param price_member: The price of a unit of this product for users marked as "members".
|
:param price_member: The price of a unit of this product for users marked as "members".
|
||||||
:param price_non_member: The price of a unit of this product for users NOT marked as "members".
|
:param price_non_member: The price of a unit of this product for users NOT marked as "members".
|
||||||
:param stock: The number of items of this product currently in stock.
|
:param stock: The number of items of this product currently in stock.
|
||||||
"""
|
"""
|
||||||
self._product_id: int = product_id
|
|
||||||
self._name: str = name
|
|
||||||
self._price_member: int = price_member
|
|
||||||
self._price_non_member: int = price_non_member
|
|
||||||
self._stock: int = stock
|
|
||||||
|
|
||||||
def __eq__(self, other: Any) -> bool:
|
id: int
|
||||||
if other is None or not isinstance(other, Product):
|
name: str
|
||||||
return False
|
price_member: int
|
||||||
return self._product_id == other._product_id \
|
price_non_member: int
|
||||||
and self._name == other._name \
|
stock: int
|
||||||
and self._price_member == other._price_member \
|
|
||||||
and self._price_non_member == other._price_non_member \
|
|
||||||
and self._stock == other._stock
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self) -> int:
|
|
||||||
return self._product_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@name.setter
|
|
||||||
def name(self, name: str) -> None:
|
|
||||||
self._name = name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def price_member(self) -> int:
|
|
||||||
return self._price_member
|
|
||||||
|
|
||||||
@price_member.setter
|
|
||||||
def price_member(self, price: int) -> None:
|
|
||||||
self._price_member = price
|
|
||||||
|
|
||||||
@property
|
|
||||||
def price_non_member(self) -> int:
|
|
||||||
return self._price_non_member
|
|
||||||
|
|
||||||
@price_non_member.setter
|
|
||||||
def price_non_member(self, price: int) -> None:
|
|
||||||
self._price_non_member = price
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stock(self) -> int:
|
|
||||||
return self._stock
|
|
||||||
|
|
||||||
@stock.setter
|
|
||||||
def stock(self, stock: int) -> None:
|
|
||||||
self._stock = stock
|
|
||||||
|
|
|
@ -1,87 +1,27 @@
|
||||||
|
|
||||||
from typing import Optional, Any
|
from typing import Optional
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
class User(object):
|
@dataclass
|
||||||
|
class User:
|
||||||
"""
|
"""
|
||||||
Representation of a user registered with the Matemat, with a name, e-mail address (optional), whether the user is a
|
Representation of a user registered with the Matemat, with a name, e-mail address (optional), whether the user is a
|
||||||
member of the organization the Matemat instance is used in, whether the user is an administrator, and the user's
|
member of the organization the Matemat instance is used in, whether the user is an administrator, and the user's
|
||||||
account balance.
|
account balance.
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self,
|
:param id: The user ID in the database.
|
||||||
user_id: int,
|
|
||||||
username: str,
|
|
||||||
balance: int,
|
|
||||||
email: Optional[str] = None,
|
|
||||||
admin: bool = False,
|
|
||||||
member: bool = True) -> None:
|
|
||||||
"""
|
|
||||||
Create a new user instance with the given arguments.
|
|
||||||
|
|
||||||
:param user_id: The user ID in the database.
|
|
||||||
:param username: The user's name.
|
:param username: The user's name.
|
||||||
:param balance: The balance of the user's account.
|
:param balance: The balance of the user's account.
|
||||||
:param email: The user's e-mail address (optional).
|
:param email: The user's e-mail address (optional).
|
||||||
:param admin: Whether the user is an administrator.
|
:param admin: Whether the user is an administrator.
|
||||||
:param member: Whether the user is a member.
|
:param member: Whether the user is a member.
|
||||||
"""
|
"""
|
||||||
self._user_id: int = user_id
|
|
||||||
self._username: str = username
|
|
||||||
self._email: Optional[str] = email
|
|
||||||
self._admin: bool = admin
|
|
||||||
self._member: bool = member
|
|
||||||
self._balance: int = balance
|
|
||||||
|
|
||||||
def __eq__(self, other: Any) -> bool:
|
id: int
|
||||||
if other is None or not isinstance(other, User):
|
name: str
|
||||||
return False
|
balance: int
|
||||||
return self._user_id == other._user_id \
|
email: Optional[str] = None
|
||||||
and self._username == other._username \
|
is_admin: bool = False
|
||||||
and self._email == other._email \
|
is_member: bool = False
|
||||||
and self._admin == other._admin \
|
|
||||||
and self._member == other._member
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self) -> int:
|
|
||||||
return self._user_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._username
|
|
||||||
|
|
||||||
@name.setter
|
|
||||||
def name(self, value):
|
|
||||||
self._username = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def email(self) -> Optional[str]:
|
|
||||||
return self._email
|
|
||||||
|
|
||||||
@email.setter
|
|
||||||
def email(self, email: str) -> None:
|
|
||||||
self._email = email
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_admin(self) -> bool:
|
|
||||||
return self._admin
|
|
||||||
|
|
||||||
@is_admin.setter
|
|
||||||
def is_admin(self, admin: bool) -> None:
|
|
||||||
self._admin = admin
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_member(self) -> bool:
|
|
||||||
return self._member
|
|
||||||
|
|
||||||
@is_member.setter
|
|
||||||
def is_member(self, member: bool) -> None:
|
|
||||||
self._member = member
|
|
||||||
|
|
||||||
@property
|
|
||||||
def balance(self) -> int:
|
|
||||||
return self._balance
|
|
||||||
|
|
||||||
@balance.setter
|
|
||||||
def balance(self, balance: int) -> None:
|
|
||||||
self._balance = balance
|
|
||||||
|
|
|
@ -71,7 +71,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
u = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com')
|
u = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com')
|
||||||
# Add a touchkey without using the provided function
|
# Add a touchkey without using the provided function
|
||||||
c.execute('''UPDATE users SET touchkey = :tkhash WHERE user_id = :user_id''', {
|
c.execute('''UPDATE users SET touchkey = :tkhash WHERE user_id = :user_id''', {
|
||||||
'tkhash': crypt.crypt('0123', crypt.mksalt(crypt.METHOD_SHA512)),
|
'tkhash': crypt.crypt('0123', crypt.mksalt()),
|
||||||
'user_id': u.id
|
'user_id': u.id
|
||||||
})
|
})
|
||||||
user = db.login('testuser', 'supersecurepassword')
|
user = db.login('testuser', 'supersecurepassword')
|
||||||
|
@ -112,7 +112,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
with self.assertRaises(AuthenticationError):
|
with self.assertRaises(AuthenticationError):
|
||||||
db.login('testuser', 'mynewpassword')
|
db.login('testuser', 'mynewpassword')
|
||||||
db.login('testuser', 'adminpasswordreset')
|
db.login('testuser', 'adminpasswordreset')
|
||||||
user._user_id = -1
|
user.id = -1
|
||||||
with self.assertRaises(AuthenticationError):
|
with self.assertRaises(AuthenticationError):
|
||||||
# Password change for an inexistent user should fail
|
# Password change for an inexistent user should fail
|
||||||
db.change_password(user, 'adminpasswordreset', 'passwordwithoutuser')
|
db.change_password(user, 'adminpasswordreset', 'passwordwithoutuser')
|
||||||
|
@ -136,7 +136,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
with self.assertRaises(AuthenticationError):
|
with self.assertRaises(AuthenticationError):
|
||||||
db.login('testuser', touchkey='4567')
|
db.login('testuser', touchkey='4567')
|
||||||
db.login('testuser', touchkey='89ab')
|
db.login('testuser', touchkey='89ab')
|
||||||
user._user_id = -1
|
user.id = -1
|
||||||
with self.assertRaises(AuthenticationError):
|
with self.assertRaises(AuthenticationError):
|
||||||
# Touchkey change for an inexistent user should fail
|
# Touchkey change for an inexistent user should fail
|
||||||
db.change_touchkey(user, '89ab', '048c')
|
db.change_touchkey(user, '89ab', '048c')
|
||||||
|
@ -157,7 +157,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
self.assertFalse(checkuser.is_admin)
|
self.assertFalse(checkuser.is_admin)
|
||||||
self.assertFalse(checkuser.is_member)
|
self.assertFalse(checkuser.is_member)
|
||||||
self.assertEqual(4200, checkuser.balance)
|
self.assertEqual(4200, checkuser.balance)
|
||||||
user._user_id = -1
|
user.id = -1
|
||||||
with self.assertRaises(DatabaseConsistencyError):
|
with self.assertRaises(DatabaseConsistencyError):
|
||||||
db.change_user(user, agent, is_member='True')
|
db.change_user(user, agent, is_member='True')
|
||||||
|
|
||||||
|
@ -238,7 +238,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
self.assertEqual(150, checkproduct.price_member)
|
self.assertEqual(150, checkproduct.price_member)
|
||||||
self.assertEqual(250, checkproduct.price_non_member)
|
self.assertEqual(250, checkproduct.price_non_member)
|
||||||
self.assertEqual(42, checkproduct.stock)
|
self.assertEqual(42, checkproduct.stock)
|
||||||
product._product_id = -1
|
product.id = -1
|
||||||
with self.assertRaises(DatabaseConsistencyError):
|
with self.assertRaises(DatabaseConsistencyError):
|
||||||
db.change_product(product)
|
db.change_product(product)
|
||||||
product2 = db.create_product('Club Mate', 200, 200)
|
product2 = db.create_product('Club Mate', 200, 200)
|
||||||
|
@ -281,7 +281,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
# Should fail, negative amount
|
# Should fail, negative amount
|
||||||
db.deposit(user, -42)
|
db.deposit(user, -42)
|
||||||
user._user_id = -1
|
user.id = -1
|
||||||
with self.assertRaises(DatabaseConsistencyError):
|
with self.assertRaises(DatabaseConsistencyError):
|
||||||
# Should fail, user id -1 does not exist
|
# Should fail, user id -1 does not exist
|
||||||
db.deposit(user, 42)
|
db.deposit(user, 42)
|
||||||
|
@ -300,7 +300,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
self.assertEqual(42, c.fetchone()[0])
|
self.assertEqual(42, c.fetchone()[0])
|
||||||
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [product2.id])
|
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [product2.id])
|
||||||
self.assertEqual(0, c.fetchone()[0])
|
self.assertEqual(0, c.fetchone()[0])
|
||||||
product._product_id = -1
|
product.id = -1
|
||||||
with self.assertRaises(DatabaseConsistencyError):
|
with self.assertRaises(DatabaseConsistencyError):
|
||||||
# Should fail, product id -1 does not exist
|
# Should fail, product id -1 does not exist
|
||||||
db.restock(product, 42)
|
db.restock(product, 42)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
@ -48,7 +49,7 @@ class DatabaseWrapper(object):
|
||||||
self._filename: str = filename
|
self._filename: str = filename
|
||||||
self._sqlite_db: Optional[sqlite3.Connection] = None
|
self._sqlite_db: Optional[sqlite3.Connection] = None
|
||||||
|
|
||||||
def __enter__(self) -> 'DatabaseWrapper':
|
def __enter__(self) -> DatabaseWrapper:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
from typing import Dict, Iterator, List, Tuple, Union
|
from typing import Dict, Iterator, List, Tuple, Union
|
||||||
|
|
||||||
|
|
||||||
|
@ -24,7 +25,7 @@ class RequestArguments(object):
|
||||||
"""
|
"""
|
||||||
self.__container: Dict[str, RequestArgument] = dict()
|
self.__container: Dict[str, RequestArgument] = dict()
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> 'RequestArgument':
|
def __getitem__(self, key: str) -> RequestArgument:
|
||||||
"""
|
"""
|
||||||
Retrieve the argument for the given name, creating it on the fly, if it doesn't exist.
|
Retrieve the argument for the given name, creating it on the fly, if it doesn't exist.
|
||||||
|
|
||||||
|
@ -40,7 +41,7 @@ class RequestArguments(object):
|
||||||
# Return the argument for the name
|
# Return the argument for the name
|
||||||
return self.__container[key]
|
return self.__container[key]
|
||||||
|
|
||||||
def __getattr__(self, key: str) -> 'RequestArgument':
|
def __getattr__(self, key: str) -> RequestArgument:
|
||||||
"""
|
"""
|
||||||
Syntactic sugar for accessing values with a name that can be used in Python attributes. The value will be
|
Syntactic sugar for accessing values with a name that can be used in Python attributes. The value will be
|
||||||
returned as an immutable view.
|
returned as an immutable view.
|
||||||
|
@ -278,7 +279,7 @@ class RequestArgument(object):
|
||||||
# Yield an immutable scalar view for each (ctype, value) element in the array
|
# Yield an immutable scalar view for each (ctype, value) element in the array
|
||||||
yield _View(self.__name, v)
|
yield _View(self.__name, v)
|
||||||
|
|
||||||
def __getitem__(self, index: Union[int, slice]) -> 'RequestArgument':
|
def __getitem__(self, index: Union[int, slice]) -> RequestArgument:
|
||||||
"""
|
"""
|
||||||
Index the argument with either an int or a slice. The returned values are represented as immutable
|
Index the argument with either an int or a slice. The returned values are represented as immutable
|
||||||
RequestArgument views.
|
RequestArgument views.
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
|
|
||||||
FROM debian:buster
|
# There is no buster image yet and stretch doesn't have a docker package. So let's just "upgrade" the image to buster.
|
||||||
|
FROM python:3.7-stretch
|
||||||
|
|
||||||
RUN useradd -d /home/matemat -m matemat
|
RUN sed -re 's/stretch/buster/g' -i /etc/apt/sources.list \
|
||||||
RUN mkdir -p /var/matemat/db && chown matemat:matemat -R /var/matemat/db
|
&& useradd -d /home/matemat -m matemat \
|
||||||
RUN mkdir -p /var/matemat/upload && chown matemat:matemat -R /var/matemat/upload
|
&& mkdir -p /var/matemat/db /var/matemat/upload \
|
||||||
RUN apt-get update -qy
|
&& chown matemat:matemat -R /var/matemat/db \
|
||||||
RUN apt-get install -y --no-install-recommends file sudo openssh-client git docker.io python3-dev python3-pip python3-coverage python3-setuptools build-essential
|
&& chown matemat:matemat -R /var/matemat/upload \
|
||||||
RUN pip3 install wheel pycodestyle mypy
|
&& apt-get update -qy \
|
||||||
|
&& apt-get install -y --no-install-recommends file sudo openssh-client git docker.io build-essential \
|
||||||
|
&& python3.7 -m pip install coverage wheel pycodestyle mypy \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /home/matemat
|
WORKDIR /home/matemat
|
||||||
|
|
Loading…
Reference in a new issue