Skip to content

Commit 0ac08b0

Browse files
Merge pull request #849 from MasoniteFramework/4.0
v4 to v5
2 parents ccbc586 + 28cf121 commit 0ac08b0

File tree

22 files changed

+155
-25
lines changed

22 files changed

+155
-25
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<h1 align="center">Masonite</h1>
44
</p>
55
<p align="center">
6-
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/MasoniteFramework/masonite/pythonapp.yml?branch=develop">
6+
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/MasoniteFramework/masonite/pythonapp.yml">
77

88
<img alt="GitHub release (latest by date including pre-releases)" src="https://img.shields.io/github/v/release/MasoniteFramework/masonite?include_prereleases">
99
<img src="https://img.shields.io/github/license/MasoniteFramework/masonite.svg" alt="License">

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jinja2<3.2
1313
masonite-orm>=2,<3
1414
pendulum>=2,<3
1515
pwnedapi
16-
vonage
16+
vonage>=3,<4
1717
pytest
1818
python-dotenv>=0.15,<0.16
1919
responses
@@ -23,4 +23,4 @@ werkzeug>=2,<3; python_version < '3.8'
2323
werkzeug>=3,<4; python_version >= '3.8'
2424
watchdog>=2,<3
2525
whitenoise>=5.2,<5.3
26-
pyjwt>=2.4,<2.5
26+
pyjwt>=2.4,<2.5

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"watchdog>=2<3",
6161
"multipart>=0.2,<0.3",
6262
"watchdog>=2,<=4",
63+
"phonenumbers>=8.12,<9",
6364
],
6465
# See https://pypi.python.org/pypi?%3Aaction=list_classifiers
6566
classifiers=[

src/masonite/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "4.20.1"
1+
__version__ = "4.20.3"

src/masonite/authorization/Gate.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def inspect(self, permission, *args):
101101
if boolean_result:
102102
return AuthorizationResponse.allow()
103103
else:
104+
self.application.make("response").status(403)
104105
return AuthorizationResponse.deny()
105106

106107
def check(self, permission, *args):

src/masonite/dumps/Dumper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ def clear(self):
1616

1717
def dd(self, *objects):
1818
"""Dump all provided args and die, raising a DumpException."""
19+
self.app.make('response').status(200)
1920
self._dump(*objects)
2021
raise DumpException()
2122

2223
def dump(self, *objects):
2324
"""Dump all provided args and continue code execution. This does not raise a DumpException."""
25+
self.app.make('response').status(200)
2426
dumps = self._dump(*objects)
2527
# output dumps in console
2628
for dump in dumps:

src/masonite/exceptions/handlers/ModelNotFoundHandler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ def handle(self, exception):
99
masonite_exception = ModelNotFoundException(
1010
"No record found with the given primary key"
1111
)
12+
self.application.make("response").status(404)
1213
self.application.make("exception_handler").handle(masonite_exception)

src/masonite/middleware/route/SessionMiddleware.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def before(self, request, response):
2121
# actually clears them out of the session
2222
errors = Session.get("errors")
2323
request.app.make("view").share({"errors": MessageBag(errors or {}).helper})
24+
# TODO: remove this in Masonite 5
25+
request.app.make("view").share({"bag": MessageBag(errors or {}).helper})
2426
# if any errors then re-add them to the session
2527
if errors:
2628
Session.flash("errors", errors)

src/masonite/notification/Notifiable.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,28 @@
66

77

88
class Notifiable:
9-
"""Notifiable mixin allowing to send notification to a model. It's often used with the
9+
"""Notifiable mixin allowing to send
10+
notification to a model. It's often used with the
1011
User model.
1112
1213
Usage:
1314
user.notify(WelcomeNotification())
1415
"""
15-
1616
def notify(self, notification, drivers=[], dry=False, fail_silently=False):
1717
"""Send the given notification."""
1818
from wsgi import application
1919

20+
if hasattr(notification, "send"):
21+
notification.send(notification, drivers, dry, fail_silently)
22+
2023
return application.make("notification").send(
2124
self, notification, drivers, dry, fail_silently
2225
)
2326

2427
def route_notification_for(self, driver):
25-
"""Get the notification routing information for the given driver. If method has not been
26-
defined on the model: for mail driver try to use 'email' field of model."""
28+
"""Get the notification routing information for the given driver.
29+
If method has not been defined on the model:
30+
for mail driver try to use 'email' field of model."""
2731
# check if routing has been specified on the model
2832
method_name = "route_notification_for_{0}".format(driver)
2933

@@ -39,7 +43,8 @@ def route_notification_for(self, driver):
3943
return self.email
4044
else:
4145
raise NotificationException(
42-
"Notifiable model does not implement {}".format(method_name)
46+
"Notifiable model "
47+
"does not implement {}".format(method_name)
4348
)
4449

4550
@has_many("id", "notifiable_id")

src/masonite/notification/drivers/vonage/VonageDriver.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ def send(self, notifiable, notification):
4040
sms = self.build(notifiable, notification)
4141
client = self.get_sms_client()
4242
recipients = sms._to
43+
if not isinstance(recipients, list):
44+
recipients = [recipients]
4345
for recipient in recipients:
46+
if not self.is_valid_phone_number(recipient):
47+
raise NotificationException(f"Invalid phone number: {recipient}")
4448
payload = sms.to(recipient).build().get_options()
4549
response = client.send_message(payload)
4650
self._handle_errors(response)
@@ -67,3 +71,11 @@ def _handle_errors(self, response):
6771
status, message["error-text"]
6872
)
6973
)
74+
75+
def is_valid_phone_number(self, phone_number):
76+
import phonenumbers
77+
try:
78+
parsed_number = phonenumbers.parse(phone_number, None)
79+
return phonenumbers.is_valid_number(parsed_number)
80+
except phonenumbers.NumberParseException:
81+
return False

src/masonite/providers/RouteProvider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def boot(self):
5656
else:
5757
response.view(data)
5858
except Exception as e:
59+
if not response.get_status_code():
60+
response.status(500)
5961
exception = e
6062

6163
self.application.make("middleware").run_route_middleware(

src/masonite/queues/Queueable.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ class Queueable:
77
run_again_on_fail = True
88
run_times = 3
99

10+
def queue(self):
11+
return "default"
12+
1013
def handle(self):
1114
pass
1215

@@ -15,3 +18,8 @@ def failed(self, obj, e):
1518

1619
def __repr__(self):
1720
return self.__class__.__name__
21+
22+
def send(self, notification, driver="", dry=False, fail_silently=False):
23+
from ..facades.Queue import Queue
24+
Queue.push(notification, queue=self.queue())
25+
print('Sending to queue', notification)

src/masonite/rates/RateLimiter.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ def too_many_attempts(self, key: str, max_attempts: int) -> bool:
4646
key = self.clean_key(key)
4747
if self.attempts(key) >= max_attempts:
4848
# trigger remove of cache value if needed
49-
self.cache.get(f"{key}:timer")
50-
if self.cache.has(f"{key}:timer"):
49+
self.cache.get(f"{key}-timer")
50+
if self.cache.has(f"{key}-timer"):
5151
return True
5252
self.reset_attempts(key)
5353
return False
@@ -56,7 +56,7 @@ def hit(self, key: str, delay: int) -> int:
5656
key = self.clean_key(key)
5757
# store timestamp when key limit be available again
5858
available_at = pendulum.now().add(seconds=delay).int_timestamp
59-
self.cache.add(f"{key}:timer", available_at, delay)
59+
self.cache.add(f"{key}-timer", available_at, delay)
6060
# ensure key exists
6161
self.cache.add(key, 0, delay)
6262
hits = self.cache.increment(key)
@@ -69,12 +69,12 @@ def reset_attempts(self, key: str) -> bool:
6969
def clear(self, key: str):
7070
key = self.clean_key(key)
7171
self.cache.forget(key)
72-
self.cache.forget(f"{key}:timer")
72+
self.cache.forget(f"{key}-timer")
7373

7474
def available_at(self, key: str) -> int:
7575
"""Get UNIX integer timestamp at which key will be available again."""
7676
key = self.clean_key(key)
77-
timestamp = int(self.cache.get(f"{key}:timer", 0))
77+
timestamp = int(self.cache.get(f"{key}-timer", 0))
7878
return timestamp
7979

8080
def available_in(self, key: str) -> int:

src/masonite/response/response.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import mimetypes
55
from pathlib import Path
66
from typing import TYPE_CHECKING, Any
7+
import types
78

89
if TYPE_CHECKING:
910
from ..foundation import Application
@@ -119,6 +120,8 @@ def data(self) -> bytes:
119120
"""Get the response content as bytes."""
120121
if isinstance(self.content, str):
121122
return bytes(self.content, "utf-8")
123+
if isinstance(self.content, types.GeneratorType):
124+
return b"".join(self.content)
122125

123126
return self.content
124127

@@ -137,6 +140,10 @@ def view(self, view: Any, status: int = 200) -> "bytes|Response":
137140
view, status = view
138141
self.status(status)
139142

143+
if isinstance(view, types.GeneratorType):
144+
self.status(status)
145+
return self
146+
140147
if not self.get_status_code():
141148
self.status(status)
142149

@@ -218,3 +225,28 @@ def download(self, name: str, location: str, force: bool = False) -> "Response":
218225
data = filelike.read()
219226

220227
return self.view(data)
228+
229+
def stream(self, name: str, location: str, force: bool = True, chunk_size: int = 8192) -> "Response":
230+
"""Set the response as a file download response using streaming."""
231+
self.status(200)
232+
233+
# Set content type and disposition headers
234+
self.header_bag.add(Header("Content-Type", "application/octet-stream"))
235+
self.header_bag.add(Header("Content-Disposition", f'attachment; filename="{name}{Path(location).suffix}"'))
236+
237+
# Get the file size and set the Content-Length header
238+
file_size = Path(location).stat().st_size
239+
self.header_bag.add(Header("Content-Length", str(file_size)))
240+
241+
# Define the generator to stream the file in chunks
242+
def file_generator():
243+
with open(location, "rb") as file:
244+
while True:
245+
chunk = file.read(chunk_size)
246+
if not chunk:
247+
break
248+
yield chunk
249+
250+
# Set the response content as the file generator and return
251+
self.content = file_generator()
252+
return self

src/masonite/stubs/templates/auth/login.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@ <h2 class="text-center font-semibold text-3xl lg:text-4xl text-gray-800">
5858

5959
<!-- Another Auth Routes -->
6060
<div class="sm:flex sm:flex-wrap mt-8 sm:mb-4 text-sm text-center">
61-
<a href="password_reset" class="flex-2 underline">
61+
<a href="{{ route('password_reset') }}" class="flex-2 underline">
6262
Forgot password?
6363
</a>
6464

6565
<p class="flex-1 text-gray-500 text-md mx-4 my-1 sm:my-auto">
6666
or
6767
</p>
6868

69-
<a href="register" class="flex-2 underline">
69+
<a href="{{ route('register') }}" class="flex-2 underline">
7070
Create an Account
7171
</a>
7272
</div>

src/masonite/utils/str.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""String generators and helpers"""
2+
23
import random
34
import string
45
from urllib import parse
@@ -80,7 +81,9 @@ def add_query_params(url: str, query_params: dict) -> str:
8081
"""Add query params dict to a given url (which can already contain some query parameters)."""
8182
path_result = parse.urlsplit(url)
8283

83-
base_url = f"{path_result.scheme}://{path_result.hostname}" if path_result.hostname else ""
84+
base_url = (
85+
f"{path_result.scheme}://{path_result.hostname}" if path_result.hostname else ""
86+
)
8487
base_path = path_result.path
8588

8689
# parse existing query parameters if any
@@ -91,7 +94,13 @@ def add_query_params(url: str, query_params: dict) -> str:
9194
if all_query_params:
9295
base_path += "?" + parse.urlencode(all_query_params)
9396

94-
return f"{base_url}{base_path}"
97+
result_url = f"{base_url}{base_path}"
98+
99+
# add fragment if exists
100+
if path_result.fragment:
101+
result_url = f"{result_url}#{path_result.fragment}"
102+
103+
return result_url
95104

96105

97106
def get_controller_name(controller: "str|Any") -> str:

tests/core/response/test_response.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,9 @@ def test_encoding_chinese_characters(self):
1515
response = Response(self.application)
1616
content = response.json({"test": "我"})
1717
self.assertEqual(content.decode("utf-8"), '{"test": "我"}')
18+
19+
def test_redirect_external(self):
20+
response = Response(self.application)
21+
response = response.redirect("https://google.com")
22+
self.assertEqual(response._status, "302 Found")
23+
self.assertEqual(response.header_bag.get("Location").value, "https://google.com")

tests/core/utils/test_str.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
removeprefix,
66
removesuffix,
77
get_controller_name,
8+
add_query_params,
89
)
910

1011
from tests.integrations.controllers.api.TestController import TestController
@@ -35,3 +36,39 @@ def test_get_controller_name(self):
3536
)
3637
controller = TestController()
3738
self.assertEqual(get_controller_name(controller), "TestController@__call__")
39+
40+
def test_add_query_params_not_break_urls(self):
41+
test_urls = [
42+
"https://example.com/c/pay/cs_teAIXs#fid2cGd2ZndsdXFsamtQa2x0cGBrYHZ2QGtkZ2lgYSc%2FY2RpdmApJ2R1bE5gfCc%2FJ3VuWnFgdnFac2FiQF9fbWppSTxkc01tcWRTMzA9fVdgNTVHfXJWcGhUPCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl",
43+
"https://example.com?a=b",
44+
"https://example.com/",
45+
]
46+
47+
for url in test_urls:
48+
self.assertEqual(url, add_query_params(url, {}))
49+
50+
def test_add_query_params_add_params(self):
51+
test_urls = [
52+
{
53+
"input": "https://example.com/c/pay/cs_teAIXs#fidx22wwe",
54+
"result": "https://example.com/c/pay/cs_teAIXs?test_param=1&test_param_2=2#fidx22wwe",
55+
},
56+
{
57+
"input": "https://example.com?a=b&",
58+
"result": "https://example.com?a=b&test_param=1&test_param_2=2",
59+
},
60+
{
61+
"input": "https://example.com",
62+
"result": "https://example.com?test_param=1&test_param_2=2",
63+
},
64+
{
65+
"input": "https://example.com/",
66+
"result": "https://example.com/?test_param=1&test_param_2=2",
67+
},
68+
]
69+
70+
for url in test_urls:
71+
self.assertEqual(
72+
url["result"],
73+
add_query_params(url["input"], {"test_param": 1, "test_param_2": 2}),
74+
)

tests/features/rates/test_rate_limiter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ def test_attempt(self):
2424
def test_clear_remove_cache_keys(self):
2525
RateLimiter.attempt("test_key", my_function, 2)
2626
assert Cache.store().has("test_key")
27-
assert Cache.store().has("test_key:timer")
27+
assert Cache.store().has("test_key-timer")
2828
RateLimiter.clear("test_key")
29-
assert not Cache.store().has("test_key:timer")
29+
assert not Cache.store().has("test_key-timer")
3030
assert not Cache.store().has("test_key")
3131

3232
def test_reset_attempts(self):
@@ -50,7 +50,7 @@ def test_hit(self):
5050
should_be_available_at = now.add(seconds=40)
5151
RateLimiter.hit("test_key", 40)
5252
assert Cache.store().get("test_key") == "1"
53-
assert Cache.store().get("test_key:timer") == str(
53+
assert Cache.store().get("test_key-timer") == str(
5454
should_be_available_at.int_timestamp
5555
)
5656

0 commit comments

Comments
 (0)